diff --git a/boot.py b/boot.py index c3448b938..8b1d37609 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 from .plugin.core.constants import ST_VERSION from .plugin.core.css import load as load_css from .plugin.core.open import g_opening_files @@ -90,10 +91,18 @@ 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 +if TYPE_CHECKING: + import asyncio + +# Uncomment to see all invocations that are marked @deprecated in the Console. +# warnings.simplefilter('always', DeprecationWarning) + __all__ = ( "DocumentSyncListener", "Listener", @@ -222,7 +231,7 @@ def show_warning() -> None: def plugin_unloaded() -> None: _unregister_all_plugins() - windows.disable() + run_coroutine(windows.disable()) for listeners in sublime_plugin.view_event_listeners.values(): for listener in listeners: if isinstance(listener, DocumentSyncListener): @@ -230,10 +239,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): @@ -262,27 +271,26 @@ 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 := 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 + if future := self._find_opening_file_future(file_name): + # The view got closed before it finished loading. This can happen. + future.set_result(None) + + def _find_opening_file_future(self, file_name: str) -> asyncio.Future[sublime.View | None] | None: 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 + 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/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/__init__.py b/plugin/__init__.py index 8c7643996..36088d5e0 100644 --- a/plugin/__init__.py +++ b/plugin/__init__.py @@ -11,6 +11,9 @@ from .api import request_handler from .api import unregister_plugin from .api import uri_handler +from .core.aio import run_coroutine +from .core.aio import run_on_asyncio_thread +from .core.aio import run_on_threadpool from .core.collections import DottedDict from .core.constants import MarkdownLangMap from .core.constants import ST_STORAGE_PATH @@ -95,6 +98,9 @@ 'register_file_watcher_implementation', 'register_plugin', 'request_handler', + 'run_coroutine', + 'run_on_asyncio_thread', + 'run_on_threadpool', 'unregister_plugin', 'uri_from_view', 'uri_handler', diff --git a/plugin/api.py b/plugin/api.py index d682aae15..a03a36b0c 100644 --- a/plugin/api.py +++ b/plugin/api.py @@ -1,9 +1,9 @@ from __future__ import annotations from ..protocol import LSPAny +from .core.aio import run_on_threadpool from .core.constants import ST_STORAGE_PATH from .core.logging import exception_log -from .core.promise import Promise from .core.protocol import Response from .core.settings import client_configs from .core.types import method2attr @@ -15,6 +15,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 @@ -54,14 +55,14 @@ # P represents the parameters *after* the 'self' argument P = TypeVar('P', bound=LSPAny) R = TypeVar('R', bound=LSPAny) -CommandHandler = Callable[['list[P] | None'], 'Promise[R]'] -CommandHandlerForDecorator = Callable[[Any, 'list[P] | None'], 'Promise[R]'] -UriHandler = Callable[['DocumentUri', sublime.NewFileFlags], 'Promise[sublime.Sheet | None]'] +CommandHandler = Callable[['list[P] | None'], 'Awaitable[R]'] +CommandHandlerForDecorator = Callable[[Any, 'list[P] | None'], 'Awaitable[R]'] +UriHandler = Callable[['DocumentUri', sublime.NewFileFlags], 'Awaitable[sublime.Sheet | None]'] # Decorator needs a dedicated type with `Any` as the first parameter representing `Self` to make its # implementation happy. I couldn't find a better way (Concatenate and ParamSpec don't seem to help here). -UriHandlerForDecorator = Callable[[Any, 'DocumentUri', sublime.NewFileFlags], 'Promise[sublime.Sheet | None]'] +UriHandlerForDecorator = Callable[[Any, 'DocumentUri', sublime.NewFileFlags], 'Awaitable[sublime.Sheet | None]'] PostResponseCallback = Callable[[], None] -RequestHandlerResponse = Union[Promise[R], Tuple[Promise[R], PostResponseCallback]] +RequestHandlerResponse = Union[R, Tuple[R, PostResponseCallback]] g_plugins: dict[str, type[AbstractPlugin | LspPlugin]] = {} @@ -225,36 +226,41 @@ def decorator(func: Callable[[Any, P], None]) -> Callable[[Any, P], None]: def request_handler( method: str -) -> Callable[[Callable[[Any, P], RequestHandlerResponse]], Callable[[Any, P, int], Promise[Response[R]]]]: # pyright: ignore[reportInvalidTypeVarUse] +) -> Callable[[Callable[[Any, P], Awaitable[RequestHandlerResponse]]], Callable[[Any, P, int], Awaitable[Response[R]]]]: # pyright: ignore[reportInvalidTypeVarUse] """ - 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. + + 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 function as a request handler. + :returns: A decorator that registers the coroutine function as a request handler. """ - def decorator(func: Callable[[Any, P], RequestHandlerResponse]) -> Callable[[Any, P, int], Promise[Response[R]]]: + def decorator( + func: Callable[[Any, P], Awaitable[RequestHandlerResponse]] + ) -> Callable[[Any, P, int], Awaitable[Response[R]]]: @wraps(func) - def wrapper(self: Any, params: P, request_id: int) -> Promise[Response[Any]]: - promise_or_tuple = func(self, params) - if isinstance(promise_or_tuple, tuple): - promise, post_request_callback = promise_or_tuple + async def wrapper(self: Any, params: P, request_id: int) -> Response[Any]: + response = await func(self, params) + if isinstance(response, tuple): + result, post_request_callback = response else: - promise = promise_or_tuple + result = response post_request_callback = None - return promise.then(lambda result: Response(request_id, result, post_request_callback)) + return Response(request_id, result, post_request_callback) setattr(wrapper, HANDLER_MARKER, method) return wrapper @@ -272,7 +278,7 @@ def command_handler(command_name: str) -> Callable[[CommandHandlerForDecorator], Usage: ```py @command_handler('typescript.rename') - def on_custom_rename(self, arguments: list[LSPAny] | None) -> Promise[LspAny]: + async def on_custom_rename(self, arguments: list[LSPAny] | None) -> LspAny: ... ``` @@ -294,14 +300,13 @@ def uri_handler(scheme: str) -> Callable[[UriHandlerForDecorator], UriHandlerFor """ Decorator to mark a method as a handler for URIs with a specific scheme. - The decorated method receives the full URI and a `sublime.NewFileFlags` bitflag and must return a `Promise` - resolved with the opened `sublime.Sheet`, or `None` if the URI could not be opened. - Decorated method is called on the async thread. + The decorated async method receives the full URI and a `sublime.NewFileFlags` bitflag and must return an opened + `sublime.Sheet`, or `None` if the URI could not be opened. Usage: ```py @uri_handler('foo') - def on_open_foo_uri(self, uri: DocumentUri, flags: sublime.NewFileFlags) -> Promise[sublime.Sheet | None]: + async def on_open_foo_uri(self, uri: DocumentUri, flags: sublime.NewFileFlags) -> sublime.Sheet | None: ... ``` @@ -453,16 +458,18 @@ def is_applicable_async(cls, context: IsApplicableContext) -> bool: return False @classmethod + @deprecated("override on_pre_start instead") def on_pre_start_async(cls, context: OnPreStartContext) -> None: + pass + + @classmethod + 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. - Mutations to `context.working_directory` and `context.variables` are picked up and used when launching the server process. @@ -471,7 +478,11 @@ def on_pre_start_async(cls, context: OnPreStartContext) -> None: :param context: The startup context. `context.configuration`, `context.variables` and `context.working_directory` can be mutated to influence how the server is launched. """ - pass + # Historically these methods tended to run relatively slow. + # We don't want to use Sublime's worker thread for this any longer. + # Utilize the default thread pool instead. + # https://docs.python.org/3/library/asyncio-dev.html#running-blocking-code + await run_on_threadpool(cls.on_pre_start_async, context) def __init__(self, weaksession: ref[Session]) -> None: """ @@ -491,14 +502,18 @@ 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] + @deprecated("override on_initialized instead") def on_initialized_async(self) -> None: + pass + + async def on_initialized(self) -> None: """ 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 + self.on_initialized_async() def on_pre_send_request_async(self, request: ClientRequest, view: sublime.View | None) -> None: """ @@ -509,7 +524,11 @@ def on_pre_send_request_async(self, request: ClientRequest, view: sublime.View | """ pass + @deprecated("override on_pre_send_response instead") def on_pre_send_response_async(self, response: ClientResponse) -> None: + pass + + async def on_pre_send_response(self, response: ClientResponse) -> None: """ Notifies about a response that is about to be sent to the language server. @@ -518,17 +537,25 @@ def on_pre_send_response_async(self, response: ClientResponse) -> None: :param response: The response object containing 'method', 'params', and 'result'. """ - pass + self.on_pre_send_response_async(response) + @deprecated("override on_pre_send_notification instead") def on_pre_send_notification_async(self, notification: ClientNotification) -> None: + pass + + async def on_pre_send_notification(self, notification: ClientNotification) -> None: """ Notifies about a notification that is about to be sent to the language server. :param notification: The notification object. The notification['params'] can be modified by the plugin. """ - pass + self.on_pre_send_notification_async(notification) + @deprecated("override on_server_response instead") def on_server_response_async(self, response: ServerResponse) -> None: + pass + + async def on_server_response(self, response: ServerResponse) -> None: """ Notifies about a response message that has been received from the language server. @@ -537,25 +564,37 @@ def on_server_response_async(self, response: ServerResponse) -> None: :param response: The response object to the request. The response['result'] field can be modified by the plugin, before it gets further handled by the LSP package. """ - pass + self.on_server_response_async(response) + @deprecated("override on_server_notification instead") def on_server_notification_async(self, notification: ServerNotification) -> None: + pass + + async def on_server_notification(self, notification: ServerNotification) -> None: """ Notifies about a notification message that has been received from the language server. :param notification: The notification object. """ - pass + self.on_server_notification_async(notification) + @deprecated("override on_text_changed instead") def on_text_changed_async(self, session_buffer: SessionBufferProtocol) -> None: - """Called when the content of the session buffer has changed or a new buffer was opened (debounced).""" pass + async def on_text_changed(self, session_buffer: SessionBufferProtocol) -> None: + """Called when the content of the session buffer has changed or a new buffer was opened (debounced).""" + self.on_text_changed_async(session_buffer) + def on_selection_modified_async(self, session_view: SessionViewProtocol) -> None: """Called after the selection has been modified in a view (debounced).""" pass + @deprecated("override on_session_end instead") def on_session_end_async(self, exit_code: int | None, exception: Exception | None) -> None: + pass + + async def on_session_end(self, exit_code: int | None, exception: Exception | None) -> None: """ Notifies about the session ending (also if the session has crashed). Provides an opportunity to clean up any stored state or delete references to the session or plugin instance that would otherwise prevent the @@ -565,9 +604,9 @@ def on_session_end_async(self, exit_code: int | None, exception: Exception | Non after this method returns. In this case exit_code and exception are None. If the session has crashed, the exit_code and an optional exception are provided. - This API is triggered on async thread. + This API is triggered on the asyncio thread. """ - pass + self.on_session_end_async(exit_code, exception) @deprecated('Use LspPlugin instead') diff --git a/plugin/code_actions.py b/plugin/code_actions.py index b5d4cf808..c112d3f3a 100644 --- a/plugin/code_actions.py +++ b/plugin/code_actions.py @@ -5,7 +5,7 @@ from ..protocol import CodeActionParams from ..protocol import Command from ..protocol import Diagnostic -from .core.promise import Promise +from .core.aio import run_coroutine from .core.protocol import Error from .core.protocol import Request from .core.registry import LspTextCommand @@ -20,8 +20,8 @@ from .lsp_task import LspTask from abc import ABC from abc import abstractmethod -from functools import partial from typing import Any +from typing import AsyncGenerator from typing import cast from typing import final from typing import List @@ -29,14 +29,13 @@ from typing import TYPE_CHECKING from typing import Union from typing_extensions import override +import asyncio import sublime if TYPE_CHECKING: from .core.sessions import AbstractViewListener 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 @@ -65,9 +64,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 @@ -87,12 +86,12 @@ class CodeActionsManager: """Manager for per-location caching of code action responses.""" def __init__(self) -> None: - self._response_cache: tuple[str, Promise[list[CodeActionsByConfigName]]] | None = None + self._response_cache: tuple[str, asyncio.Future[list[CodeActionsByConfigName]]] | None = None self.menu_actions_cache_key: str | None = None self.refactor_actions_cache: list[tuple[str, CodeAction]] = [] self.source_actions_cache: list[tuple[str, CodeAction]] = [] - def request_for_region_async( + def request_for_region( self, view: sublime.View, region: sublime.Region, @@ -101,7 +100,7 @@ def request_for_region_async( *, manual: bool = False, progress: bool = False, - ) -> Promise[list[CodeActionsByConfigName]]: + ) -> asyncio.Future[list[CodeActionsByConfigName]]: """ Requests code actions with provided diagnostics and specified region. If there are no diagnostics for given session, the request will be made with empty diagnostics list. @@ -109,7 +108,9 @@ def request_for_region_async( listener = windows.listener_for_view(view) if not listener: self.menu_actions_cache_key = None - return Promise.resolve([]) + future = asyncio.get_running_loop().create_future() + future.set_result([]) + return future location_cache_key = None use_cache = not manual if use_cache: @@ -159,70 +160,61 @@ def response_filter(sb: SessionBufferProtocol, actions: list[CodeActionOrCommand ) ] - task = self._collect_code_actions_async(listener, request_factory, response_filter) + task = asyncio.ensure_future(self._collect_code_actions(listener, request_factory, response_filter)) if location_cache_key: self._response_cache = (location_cache_key, task) return task - def _collect_code_actions_async( + async def _collect_code_actions( self, listener: AbstractViewListener, - request_factory: Callable[[SessionBufferProtocol], Request[CodeActionParams, list[CodeActionOrCommand] | None] | None], # noqa: E501 + request_factory: Callable[ + [SessionBufferProtocol], Request[CodeActionParams, list[CodeActionOrCommand] | None] | None + ], response_filter: Callable[[SessionBufferProtocol, list[CodeActionOrCommand]], list[CodeActionOrCommand]], - ) -> Promise[list[CodeActionsByConfigName]]: - - def on_response( - sb: SessionBufferProtocol, response: Error | list[CodeActionOrCommand] | None - ) -> CodeActionsByConfigName: - actions = [] - if response and not isinstance(response, Error): - actions = response_filter(sb, response) - return (sb.session.config.name, actions) - - tasks: list[Promise[CodeActionsByConfigName]] = [] + ) -> list[CodeActionsByConfigName]: + results: list[CodeActionsByConfigName] = [] 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_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)) - # Return only results for non-empty lists. - return Promise.all(tasks) \ - .then(lambda actions_list: list(filter(lambda actions: len(actions[1]), actions_list))) - - def request_on_save_or_format_async( + await listener.purge_changes() + await sb.do_document_diagnostic(listener.view, listener.view.change_count()) + if ( + (response := await sb.session.request(request)) + and not isinstance(response, Error) + # Return only results for non-empty lists. + and (code_actions := response_filter(sb, response)) + ): + results.append((sb.session.config.name, code_actions)) + return results + + async def request_on_save_or_format( self, view: sublime.View, code_actions: dict[str, bool] - ) -> Generator[Promise[CodeActionsByConfigName]]: + ) -> AsyncGenerator[CodeActionsByConfigName]: listener = windows.listener_for_view(view) if not listener: return - def on_response( - sb: SessionBufferProtocol, response: Error | list[CodeActionOrCommand] | None - ) -> CodeActionsByConfigName: - actions = [] - 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 - # actions that need to be then manually filtered. - 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')] - 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)) for kind in matching_kinds: - listener.purge_changes_async() + await listener.purge_changes() # Pull for diagnostics to ensure that server computes them before receiving code action request. - sb.do_document_diagnostic_async(view, view.change_count()) + await 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) - yield sb.session.send_request_task(Request.codeAction(params, view)).then(partial(on_response, sb)) + actions = [] + if (response := await sb.session.request(Request.codeAction(params, view))) 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 + # actions that need to be then manually filtered. + 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')] + yield (sb.session.config.name, actions) actions_manager = CodeActionsManager() @@ -283,35 +275,18 @@ 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[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)) + async for config_name, code_actions in actions_manager.request_on_save_or_format(view, code_action_kinds): + if code_actions and (session := self._text_command.session_by_name(config_name, 'codeActionProvider')): + await asyncio.gather( + *( + session.run_code_action(action, progress=False, view=self._text_command.view) + for action in code_actions + ) + ) @final @@ -388,9 +363,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(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: @@ -399,9 +374,10 @@ def _run_async(self, only_kinds: list[str | CodeActionKind] | None = None) -> No if not listener: return session_buffer_diagnostics = listener.get_diagnostics_async(region) - actions_manager.request_for_region_async( + actions = await actions_manager.request_for_region( view, region, session_buffer_diagnostics, only_kinds, manual=True, progress=True - ).then(lambda actions: sublime.set_timeout(lambda: self._handle_code_actions(actions))) + ) + sublime.set_timeout(lambda: self._handle_code_actions(actions)) def _handle_code_actions(self, response: list[CodeActionsByConfigName], run_first: bool = False) -> None: # Flatten response to a list of (config_name, code_action) tuples. @@ -429,13 +405,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(run()) def _handle_response_async(self, session_name: str, response: Any) -> None: if isinstance(response, Error): @@ -465,7 +441,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)) + run_coroutine(self._request_menu_actions(event)) return False return index < len(self.actions_cache) and self._is_cache_valid(event) @@ -490,14 +466,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(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): @@ -523,12 +499,12 @@ def _get_region(self, event: dict | None) -> sublime.Region | None: def applies_to_context_menu(event: dict | None) -> bool: return event is not None and 'x' in event - def _request_menu_actions_async(self, event: dict | None) -> None: + async def _request_menu_actions(self, event: dict | None) -> None: view = self.view if not view: return if (region := self._get_region(event)) is not None: - actions_manager.request_for_region_async(view, region, [], MENU_ACTIONS_KINDS, manual=True) + await actions_manager.request_for_region(view, region, [], MENU_ACTIONS_KINDS, manual=True) class LspRefactorCommand(LspMenuActionCommand): diff --git a/plugin/code_lens.py b/plugin/code_lens.py index a022945af..7320e7f1f 100644 --- a/plugin/code_lens.py +++ b/plugin/code_lens.py @@ -1,13 +1,14 @@ from __future__ import annotations +from .core.aio import run_coroutine from .core.constants import CODE_LENS_ENABLED_KEY from .core.protocol import Error +from .core.protocol import Request from .core.protocol import ResolvedCodeLens from .core.registry import LspTextCommand 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 @@ -18,6 +19,7 @@ from ..protocol import CodeLens from ..protocol import Command from ..protocol import Range + from .core.sessions import Session def is_resolved(code_lens: CodeLens | ResolvedCodeLens) -> TypeGuard[ResolvedCodeLens]: @@ -49,13 +51,14 @@ def __init__(self, data: CodeLens) -> None: self.range = HashableRange(data['range']) self.cached_command = data.get('command') - def on_resolve(self, response: CodeLens | Error) -> None: - if isinstance(response, Error): - return - assert is_resolved(response) - self.data = response - self.range = HashableRange(response['range']) - self.cached_command = response['command'] + async def resolve(self, session: Session, view: sublime.View) -> CachedCodeLens: + response = await session.request(Request('codeLens/resolve', self.data, view)) + if not isinstance(response, Error): + assert is_resolved(response) + self.data = response + self.range = HashableRange(response['range']) + self.cached_command = response['command'] + return self class CodeLensCache: @@ -128,16 +131,16 @@ 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)) + run_coroutine(self._update_views(enable)) - def _update_views_async(self, enable: bool) -> None: + async def _update_views(self, enable: bool) -> None: window_manager = windows.lookup(self.window) if not window_manager: return for session in window_manager.get_sessions(): for session_view in session.session_views_async(): if enable: - session_view.session_buffer.do_code_lenses_async(session_view.view) + await session_view.session_buffer.do_code_lenses(session_view.view) else: session_view.clear_code_lenses_async() 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..deebf547e 100644 --- a/plugin/completion.py +++ b/plugin/completion.py @@ -14,14 +14,15 @@ from ..protocol import MarkupKind from ..protocol import Range from ..protocol import TextEdit +from .core.aio import run_coroutine from .core.constants import COMPLETION_KINDS from .core.constants import MarkdownLangMap from .core.edit import apply_text_edits from .core.logging import debug -from .core.promise import Promise from .core.protocol import Error from .core.protocol import Request from .core.registry import LspTextCommand +from .core.sessions import Session from .core.settings import userprefs from .core.views import FORMAT_MARKUP_CONTENT from .core.views import FORMAT_STRING @@ -31,27 +32,27 @@ from .core.views import show_lsp_popup from .core.views import text_document_position_params from typing import Any -from typing import Callable from typing import cast from typing import Generator +from typing import Iterator from typing import List from typing import Tuple from typing import TYPE_CHECKING from typing import Union from typing_extensions import TypeAlias from typing_extensions import TypeGuard -import functools +import asyncio import html import sublime -import weakref import webbrowser if TYPE_CHECKING: - from .core.sessions import Session + from .core.sessions import CancellableRequest + from .core.sessions import RequestController SessionName: TypeAlias = str -CompletionResponse: TypeAlias = Union[List[CompletionItem], CompletionList, Error, None] -ResolvedCompletions: TypeAlias = Tuple[CompletionResponse, 'weakref.ref[Session]'] +CompletionResponse: TypeAlias = Union[List[CompletionItem], CompletionList, None] +ResolvedCompletions: TypeAlias = Tuple[Union[CompletionResponse, BaseException], Session] CompletionsStore: TypeAlias = Tuple[List[CompletionItem], CompletionItemDefaults] @@ -180,66 +181,56 @@ def completion_with_defaults(item: CompletionItem, item_defaults: CompletionItem class QueryCompletionsTask: - """ - Represents pending completion requests. - - Can be canceled while in progress in which case the "on_done_async" callback will get immediately called with empty - list and the pending response from the server(s) will be canceled and results ignored. - - All public methods must only be called on the async thread and the "on_done_async" callback will also be called - on the async thread. - """ + """Represents pending completion requests.""" def __init__( self, view: sublime.View, location: int, - triggered_manually: bool, - on_done_async: Callable[[list[sublime.CompletionItem], sublime.AutoCompleteFlags], None] + triggered_manually: bool ) -> None: self._view = view self._location = location self._triggered_manually = triggered_manually - self._on_done_async = on_done_async - self._resolved = False - self._pending_completion_requests: dict[int, weakref.ref[Session]] = {} - - def query_completions_async(self, sessions: list[Session]) -> None: - promises = [self._create_completion_request_async(session) for session in sessions] - Promise.all(promises).then(self._resolve_completions_async) - - def _create_completion_request_async(self, session: Session) -> Promise[ResolvedCompletions]: + self._pending_completion_requests: dict[int, RequestController] = {} + + async def query_completions( + self, sessions: list[Session] + ) -> tuple[list[sublime.CompletionItem], sublime.AutoCompleteFlags]: + return self._resolve_completions_async( + zip( + await asyncio.gather( + *(self._create_completion_request_async(session) for session in sessions), + return_exceptions=True, + ), + sessions, + ) + ) + + def _create_completion_request_async(self, session: Session) -> CancellableRequest[CompletionResponse]: params = cast('CompletionParams', text_document_position_params(self._view, self._location)) request = Request.complete(params, self._view) - promise, request_id = session.send_request_task_2(request) - weak_session = weakref.ref(session) - self._pending_completion_requests[request_id] = weak_session - return promise.then(lambda response: self._on_completion_response_async(response, request_id, weak_session)) - - def _on_completion_response_async( - self, response: CompletionResponse, request_id: int, weak_session: weakref.ref[Session] - ) -> ResolvedCompletions: - self._pending_completion_requests.pop(request_id, None) - return (response, weak_session) - - def _resolve_completions_async(self, responses: list[ResolvedCompletions]) -> None: - if self._resolved: - return + future = session.request(request) + req_id = future.id + self._pending_completion_requests[req_id] = future + future.add_done_callback(lambda f: self._pending_completion_requests.pop(req_id)) + return future + + def _resolve_completions_async( + self, responses: Iterator[ResolvedCompletions] + ) -> tuple[list[sublime.CompletionItem], sublime.AutoCompleteFlags]: LspSelectCompletionCommand.completions = {} items: list[sublime.CompletionItem] = [] item_defaults: CompletionItemDefaults = {} - errors: list[Error] = [] + errors: list[BaseException] = [] flags = self._get_userpref_flags() view_settings = self._view.settings() include_snippets = view_settings.get("auto_complete_include_snippets") and \ (self._triggered_manually or view_settings.get("auto_complete_include_snippets_when_typing")) - for response, weak_session in responses: - if isinstance(response, Error): + for response, session in responses: + if isinstance(response, BaseException): errors.append(response) continue - session = weak_session() - if not session: - continue response_items: list[CompletionItem] = [] if isinstance(response, dict): response_items = response["items"] or [] @@ -262,22 +253,7 @@ def _resolve_completions_async(self, responses: list[ResolvedCompletions]) -> No if errors: error_messages = ", ".join(str(error) for error in errors) sublime.status_message(f'Completion error: {error_messages}') - self._resolve_task_async(items, flags) - - def cancel_async(self) -> None: - self._resolve_task_async([], self._get_userpref_flags()) - self._cancel_pending_requests_async() - - def _cancel_pending_requests_async(self) -> None: - # Iterate a copy of the dictionary since keys are popped on canceling. - for request_id, weak_session in self._pending_completion_requests.copy().items(): - if session := weak_session(): - session.cancel_request_async(request_id) - - def _resolve_task_async(self, completions: list[sublime.CompletionItem], flags: sublime.AutoCompleteFlags) -> None: - if not self._resolved: - self._resolved = True - self._on_done_async(completions, flags) + return items, flags def _get_userpref_flags(self) -> sublime.AutoCompleteFlags: prefs = userprefs() @@ -292,21 +268,17 @@ 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(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) - - def _handle_resolve_response_async(self, language_map: MarkdownLangMap | None, item: CompletionItem) -> None: + 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) + language_map: MarkdownLangMap | None = None + if session := self.session_by_name(session_name): + language_map = session.markdown_language_id_to_st_syntax_map() + if session.has_capability('completionProvider.resolveProvider', check_views=True): + resolved_item = await session.request(Request.resolveCompletionItem(item, self.view)) + item = resolved_item if not isinstance(resolved_item, Error) else item detail = "" documentation = "" if item: @@ -385,33 +357,28 @@ 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 - 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( - 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)) + run_coroutine(self._run(session_name, item)) - def _on_resolved(self, session_name: str, item: CompletionItem) -> None: - if additional_edits := item.get('additionalTextEdits', []): - apply_text_edits(self.view, additional_edits) + async def _run(self, session_name: str, item: CompletionItem) -> None: + session = self.session_by_name(session_name, 'completionProvider.resolveProvider') + if session and not item.get('additionalTextEdits'): + resolved_item = await session.request(Request.resolveCompletionItem(item, self.view)) + if isinstance(resolved_item, Error): + debug("Error resolving completion item:", resolved_item) + else: + item = resolved_item + 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/configuration.py b/plugin/configuration.py index 13017c5b9..95478db9f 100644 --- a/plugin/configuration.py +++ b/plugin/configuration.py @@ -1,10 +1,10 @@ from __future__ import annotations +from .core.aio import run_on_asyncio_thread 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)) + run_on_asyncio_thread(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)) + run_on_asyncio_thread(wm.disable_config_async, config_name) diff --git a/plugin/core/active_request.py b/plugin/core/active_request.py index e34a9a327..f8c4e7f79 100644 --- a/plugin/core/active_request.py +++ b/plugin/core/active_request.py @@ -4,24 +4,25 @@ from .progress import ViewProgressReporter from .progress import WindowProgressReporter from typing import Any +from typing import Coroutine from typing import TYPE_CHECKING from weakref import ref import sublime if TYPE_CHECKING: from .protocol import Request + from .sessions import RequestController from .sessions import SessionViewProtocol class ActiveRequest: """Holds state per request.""" - def __init__(self, sv: SessionViewProtocol, request_id: int, request: Request[Any, Any]) -> None: + def __init__(self, sv: SessionViewProtocol, controller: RequestController, request: Request[Any, Any]) -> None: # sv is the parent object; there is no need to keep it alive explicitly. self.weaksv = ref(sv) - self.request_id = request_id + self.controller = controller self.request = request - self.canceled = False self.progress: ProgressReporter | None = None # `request.progress` is either a boolean or a string. If it's a boolean, then that signals that the server does # not support client-initiated progress. However, for some requests we still want to notify some kind of @@ -47,8 +48,10 @@ def show() -> None: sublime.set_timeout_async(show, 200) + def cancel(self) -> Coroutine[None, None, int | None]: + return self.controller.cancel() + def on_request_canceled_async(self) -> None: - self.canceled = True self.progress = None def _start_progress_reporter_async( @@ -58,16 +61,16 @@ def _start_progress_reporter_async( percentage: float | None = None ) -> ProgressReporter | None: sv = self.weaksv() - if not sv: + if not sv or self.controller.cancelled: return None if self.request.view is not None: - key = f"lspprogressview-{sv.session.config.name}-{self.request.view.id()}-{self.request_id}" + key = f"lspprogressview-{sv.session.config.name}-{self.request.view.id()}-{self.controller.id}" return ViewProgressReporter(self.request.view, key, title, message, percentage) - key = f"lspprogresswindow-{sv.session.config.name}-{sv.session.window.id()}-{self.request_id}" + key = f"lspprogresswindow-{sv.session.config.name}-{sv.session.window.id()}-{self.controller.id}" return WindowProgressReporter(sv.session.window, key, title, message, percentage) def update_progress_async(self, params: dict[str, Any]) -> None: - if self.canceled: + if self.controller.cancelled: return value = params['value'] kind = value['kind'] diff --git a/plugin/core/aio.py b/plugin/core/aio.py new file mode 100644 index 000000000..5cad9148e --- /dev/null +++ b/plugin/core/aio.py @@ -0,0 +1,258 @@ +"""Functionality wrapping asyncio, sublime_aio, and interaction nuances with Sublime Text.""" + +from __future__ import annotations + +from .logging import debug +from .logging import exception_log +from .promise import Promise +from .protocol import Error +from .protocol import ErrorCodes +from functools import partial +from typing import Any +from typing import Callable +from typing import Coroutine +from typing import TYPE_CHECKING +import asyncio +import sublime +import sublime_aio + +if TYPE_CHECKING: + from contextvars import Context + from plugin.core.promise import ResolveFunc + from sublime_aio import T + from sublime_aio import Ts + from typing_extensions import ParamSpec + from typing_extensions import Unpack + import concurrent.futures + + P = ParamSpec("P") + + +_futures: set[concurrent.futures.Future] = set() + + +def _on_future_done(fut: concurrent.futures.Future[Any]) -> None: + _futures.discard(fut) + if not fut.cancelled() and (ex := fut.exception()): + exception_log("coroutine finished with exception", ex) + + +def run_coroutine(coroutine: Coroutine[object, object, T]) -> concurrent.futures.Future[T]: + """ + Start the execution of a coroutine in the asyncio thread, from any thread. + + :param coroutine: a coroutine to run. + :return: a handle to a concurrent future object. + + When you are certain you are already in the asyncio thread, then use one of: + + * [asyncio.create_task](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.create_task). The + caveat for asyncio.create_task is that the returned [Task](https://docs.python.org/3/library/asyncio-task.html#asyncio.Task) + object must be kept alive manually. The event loop only keeps a weak reference to the Task object. + * `TaskContainer.create_task`: restricts the lifetime of the task to the lifetime of the `TaskContainer`. Unlike + `asyncio.create_task`, keeps a (strong) reference to the Task object. + """ + future = sublime_aio.run_coroutine(coroutine) + future.add_done_callback(_on_future_done) + _futures.add(future) + return future + + +def run_on_asyncio_thread( + f: Callable[[Unpack[Ts]], Any], *args: Unpack[Ts], context: Context | None = None +) -> asyncio.Handle: + """Invoke a function in the asyncio thread, from any thread.""" + return sublime_aio.call_soon_threadsafe(f, *args, context=context) + + +def run_on_threadpool(f: Callable[[Unpack[Ts]], T], *args: Unpack[Ts]) -> asyncio.Future[T]: + """Invoke a function on the loop's default thread pool. Must be invoked from the asyncio thread.""" + return sublime_aio.run_in_worker(f, *args) + + +def _run_on_st_thread( + dispatch_func: Callable[[Callable[[], None]], None], f: Callable[P, T], *args: P.args, **kwargs: P.kwargs +) -> asyncio.Future[T]: + loop = asyncio.get_running_loop() + future = loop.create_future() + + def on_done(result: T) -> None: + if not future.done(): + future.set_result(result) + + def on_exception(ex: BaseException) -> None: + if not future.done(): + future.set_exception(ex) + + def wrap() -> None: + try: + loop.call_soon_threadsafe(on_done, f(*args, **kwargs)) + except BaseException as ex: + loop.call_soon_threadsafe(on_exception, ex) + + dispatch_func(wrap) + return future + + +def run_on_main_thread(f: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> asyncio.Future[T]: + """ + Run a function in Sublime's main (UI) thread. + + Must be called from the asyncio thread. You must await the returned future. + """ + return _run_on_st_thread(sublime.set_timeout, f, *args, **kwargs) + + +def run_on_worker_thread(f: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> asyncio.Future[T]: + """ + Run a function in Sublime's async, or worker, thread. + + Must be called from the asyncio thread. You must await the returned future. + """ + return _run_on_st_thread(sublime.set_timeout_async, f, *args, **kwargs) + + +def tick() -> asyncio.Future[None]: + """ + Wait until at least 1 tick has occurred on the main thread. + + Must be called from the asyncio thread. You must await the returned future. + """ + loop = asyncio.get_running_loop() + future = loop.create_future() + + def on_done() -> None: + # Future may have been cancelled. + if not future.done(): + future.set_result(None) + + sublime.set_timeout(lambda: loop.call_soon_threadsafe(on_done)) + return future + + +def get_clipboard() -> asyncio.Future[str]: + """ + Get the clipboard content. + + Must be called from the asyncio thread. You must await the returned future. + """ + loop = asyncio.get_running_loop() + future = loop.create_future() + + def on_done(content: str) -> None: + # Future may have been cancelled. + if not future.done(): + future.set_result(content) + + # See: https://github.com/sublimehq/sublime_text/issues/6920 + sublime.get_clipboard_async(partial(loop.call_soon_threadsafe, on_done)) # type: ignore + return future + + +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] = [] + for item in await asyncio.gather(*coros, return_exceptions=True): + # 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 + starting coroutines. + + Note: don't forget to call `super().__init__()` when using this class. + + Ensure the `cancel_all_tasks` async function is ran before this class is destroyed. + """ + + def __init__(self) -> None: + self._tasks: set[asyncio.Task] = set() + + def __del__(self) -> None: + if self._tasks: + debug("WARNING: TaskContainer is destroyed but there are still tasks running!") + + async def cancel_all_tasks(self) -> list[Exception]: + """Cancel all running tasks.""" + tasks = list(self._tasks) + for task in tasks: + task.cancel() + + 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], name: str | None = None) -> asyncio.Task | None: + """ + Spawn a new coroutine, to be run in the background. Not thread-safe. Must be invoked from the asyncio thread. + + 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. + + :param coro: The coroutine object to schedule. + :param name: An optional name to give to the task. If no name has been explicitly assigned to the Task, the + default asyncio Task implementation generates a default name during instantiation. + :return: the newly created [Task](https://docs.python.org/3/library/asyncio-task.html#task-object) object. + + """ + task = asyncio.create_task(coro, name=name) + try: + tasks = self._tasks + except AttributeError: + # This object already died on *some* thread... Most likely DocumentSyncListener. + return None + tasks.add(task) + + def on_done(t: asyncio.Task) -> None: + tasks.discard(t) + if t.cancelled(): + return + if ex := t.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], name: str | None = None) -> None: + """ + Spawn a new coroutine, to be run in the background. Thread-safe. + + The parameters and behavior of this method are exactly the same as :py:meth`create_task`. + """ + run_on_asyncio_thread(lambda: self.create_task(coro, name=name)) + + def create_task_and_wrap_in_promise( + self, coro: Coroutine[T, Any, Any], name: str | None = None + ) -> Promise[T | Error]: + + def executor_func(resolve: ResolveFunc) -> None: + + def on_asyncio_thread() -> None: + if task := self.create_task(coro, name=name): + + def handle_on_done(f: asyncio.Future[T]) -> None: + if ex := f.exception(): + resolve(Error.from_exception(ex)) + else: + resolve(f.result()) + + task.add_done_callback(handle_on_done) + else: + resolve(Error(ErrorCodes.UnknownErrorCode, "unable to create task")) + + try: + asyncio.get_running_loop() + on_asyncio_thread() + except RuntimeError: + pass + run_on_asyncio_thread(on_asyncio_thread) + + return Promise(executor_func) diff --git a/plugin/core/configurations.py b/plugin/core/configurations.py index 3862b40ab..90274794c 100644 --- a/plugin/core/configurations.py +++ b/plugin/core/configurations.py @@ -16,6 +16,7 @@ from typing import Literal from typing import TYPE_CHECKING from weakref import WeakSet +import asyncio if TYPE_CHECKING: import sublime @@ -183,6 +184,8 @@ def record_crash(self, config_name: str, exit_code: int, exception: Exception | crash_count = len([crash for crash in self._crashes[config_name] if crash > timeout]) printf(f"{config_name} crashed ({crash_count} / {RETRY_MAX_COUNT} times in the last " f"{RETRY_COUNT_TIMEDELTA.total_seconds()} seconds), exit code {exit_code}, exception: {exception}") + if isinstance(exception, asyncio.IncompleteReadError): + printf(f"server's output:\n{exception.partial.decode()}") return crash_count < RETRY_MAX_COUNT def _reenable_disabled_for_session(self, config_name: str) -> bool: diff --git a/plugin/core/edit.py b/plugin/core/edit.py index 211867a86..1fe67e2ce 100644 --- a/plugin/core/edit.py +++ b/plugin/core/edit.py @@ -119,6 +119,7 @@ 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. diff --git a/plugin/core/open.py b/plugin/core/open.py index ae4c9d312..f3b9c1d5b 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -1,17 +1,17 @@ from __future__ import annotations +from .aio import run_on_main_thread from .constants import ST_PACKAGES_PATH from .constants import ST_PLATFORM from .constants import ST_VERSION from .logging import exception_log -from .promise import Promise -from .promise import ResolveFunc from .protocol import UINT_MAX from .url import parse_uri from .views import range_to_region from typing import TYPE_CHECKING from urllib.parse import unquote from urllib.parse import urlparse +import asyncio import os import re import sublime @@ -23,10 +23,18 @@ 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 | 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}} @@ -48,21 +56,17 @@ 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: - if view: - return center_selection(view, r) - return None + if ( + (view := await open_file(window, decoded_uri, flags, group)) + and (fragment := urlparse(decoded_uri).fragment) + and (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: @@ -84,52 +88,58 @@ 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. """ + future: asyncio.Future[sublime.View | None] | None = None 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 - if not was_already_open and not os.path.isfile(file): - # window.open_file creates a new view with empty content if the path from the given URI doesn't exist as a file - # on disk, but we don't want that here. If the language server wants to create a new file for a given URI, it - # must use the CreateFile resource operation in a WorkspaceEdit. - return Promise.resolve(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(): - 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 + 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 + # Return the unresolved future. A future on_load event will resolve the future. + future = fut + break + if future is None: + loop = asyncio.get_running_loop() + future = loop.create_future() + + def resolve_right_now(view: sublime.View | None) -> None: + future.set_result(view) + + def resolve_later() -> None: + g_opening_files[file] = future + + 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(lambda: resolve_right_now(view)) + return + was_already_open = view is not None + if not was_already_open and not os.path.isfile(file): + # window.open_file creates a new view with empty content if the path from the given URI doesn't + # exist as a file on disk, but we don't want that here. If the language server wants to create a new + # file for a given URI, it must use the CreateFile resource operation in a WorkspaceEdit. + loop.call_soon_threadsafe(lambda: resolve_right_now(view)) + return + 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)) + loop.call_soon_threadsafe(resolve_later) + + await run_on_main_thread(on_main_thread) + return await future def open_resource(window: sublime.Window, uri: DocumentUri, group: int = -1) -> sublime.View | None: diff --git a/plugin/core/promise.py b/plugin/core/promise.py index ac5d1574d..08331b44d 100644 --- a/plugin/core/promise.py +++ b/plugin/core/promise.py @@ -1,14 +1,24 @@ from __future__ import annotations +from .protocol import Error +from typing import Any 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 +import asyncio import functools +import inspect import threading +if TYPE_CHECKING: + from collections.abc import Coroutine + + T = TypeVar('T') S = TypeVar('S') TExecutor = TypeVar('TExecutor') @@ -106,6 +116,28 @@ 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 | Error]: + """Wrap a task in a Promise. The Promise resolves when the task is done.""" + + def executor(resolve: ResolveFunc[T | Error]) -> None: + + def on_done(t: asyncio.Task[T]) -> None: + if ex := t.exception(): + resolve(Error.from_exception(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 | Error]: + """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. @staticmethod def all(promises: list[Promise[S]]) -> Promise[list[S]]: @@ -207,6 +239,28 @@ def async_wrapper(resolve_fn: ResolveFunc[TResult]) -> None: return Promise(sync_wrapper) return Promise(async_wrapper) + 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: + future.set_result(self.value) + else: + + def resolve_callback(resolve_value: T) -> None: + + def set_result(resolve_value: T) -> None: + # Future may have been cancelled. + if not future.done(): + future.set_result(resolve_value) + + # We don't know from which thread we are resolving, so use call_soon_threadsafe. + loop.call_soon_threadsafe(functools.partial(set_result, resolve_value)) + + self.callbacks.append(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: @@ -215,6 +269,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: diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py index 81b15e9b4..69269e6fa 100644 --- a/plugin/core/protocol.py +++ b/plugin/core/protocol.py @@ -325,7 +325,7 @@ def __str__(self) -> str: return f"{super().__str__()} ({self._code})" @classmethod - def from_exception(cls, ex: Exception) -> Error: + def from_exception(cls, ex: BaseException) -> Error: return Error(ErrorCodes.InternalError, str(ex)) diff --git a/plugin/core/registry.py b/plugin/core/registry.py index 7db6e3b6e..269b5d85c 100644 --- a/plugin/core/registry.py +++ b/plugin/core/registry.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .aio import run_coroutine from .settings import userprefs from .views import first_selection_region from .views import get_uri_and_position_from_location @@ -201,23 +202,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(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): @@ -240,21 +237,18 @@ 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) + # TODO: handle exception list? + run_coroutine(wm.restart_sessions([self._config_names[index]])) 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(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( diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 895259798..1c2c472d9 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -85,6 +85,7 @@ from ...protocol import WorkspaceClientCapabilities from ...protocol import WorkspaceDiagnosticParams from ...protocol import WorkspaceDiagnosticReport +from ...protocol import WorkspaceDiagnosticReportPartialResult from ...protocol import WorkspaceDocumentDiagnosticReport from ...protocol import WorkspaceEdit from ...protocol import WorkspaceFolder as LspWorkspaceFolder @@ -99,6 +100,11 @@ from ..diagnostics import DiagnosticsStorage from ..diagnostics import WORKSPACE_DIAGNOSTICS_RETRIGGER_DELAY from ..locationpicker import LocationPicker +from .aio import gather_and_flatten_exceptions +from .aio import run_on_asyncio_thread +from .aio import run_on_main_thread +from .aio import TaskContainer +from .aio import tick from .constants import ChangeEventAction from .constants import MarkdownLangMap from .constants import MARKO_MD_PARSER_VERSION @@ -118,6 +124,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 .logging import printf from .open import center_selection from .open import open_externally @@ -148,7 +155,6 @@ 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 @@ -174,26 +180,30 @@ from enum import IntFlag from functools import lru_cache from functools import partial -from operator import itemgetter from pathlib import Path from typing import Any from typing import Callable from typing import cast +from typing import Coroutine from typing import Generator +from typing import Generic from typing import Literal from typing import overload from typing import Protocol from typing import TYPE_CHECKING -from typing import Union +from typing import TypeVar +from typing_extensions import deprecated from typing_extensions import TypeAlias from typing_extensions import TypeGuard from urllib.parse import urldefrag from urllib.parse import urlparse from weakref import WeakSet +import asyncio import itertools import mdpopups import os import sublime +import threading import weakref if TYPE_CHECKING: @@ -201,6 +211,9 @@ from .collections import DottedDict +T = TypeVar('T') + + InitCallback: TypeAlias = Callable[['Session', bool], None] @@ -288,13 +301,12 @@ 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, 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. - 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. + Returns the initialized Session object, or None if nothing was started. """ raise NotImplementedError @@ -305,20 +317,18 @@ 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 @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( - self, config_name: str, params: ShowMessageParams - ) -> Promise[MessageActionItem | None]: + def handle_show_message(self, config_name: str, params: ShowMessageParams) -> None: ... @abstractmethod @@ -710,15 +720,12 @@ 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: ... - def on_request_started_async(self, request_id: int, request: Request[Any, Any]) -> None: - ... - def on_request_finished_async(self, request_id: int) -> None: ... @@ -749,6 +756,9 @@ def get_request_flags(self) -> RequestFlags: class SessionBufferProtocol(Protocol): + def create_task(self, coro: Coroutine[object, object, T], *, name: str | None = None) -> asyncio.Task[T] | None: + ... + @property def session(self) -> Session: ... @@ -812,7 +822,7 @@ def get_document_link_at_point(self, view: sublime.View, point: int) -> Document def update_document_link(self, new_link: DocumentLink) -> None: ... - def do_semantic_tokens_async(self, view: sublime.View) -> None: + async def do_semantic_tokens(self, view: sublime.View) -> None: ... def get_semantic_tokens(self) -> list[SemanticToken]: @@ -821,7 +831,7 @@ def get_semantic_tokens(self) -> list[SemanticToken]: def on_color_scheme_changed(self, view: sublime.View) -> None: ... - def do_inlay_hints_async(self, view: sublime.View) -> None: + async def do_inlay_hints(self, view: sublime.View) -> None: ... def remove_inlay_hint_phantom(self, phantom_uuid: str) -> None: @@ -830,8 +840,9 @@ 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: - ... + async def do_document_diagnostic( + self, view: sublime.View, version: int, *, forced_update: bool = ... + ) -> list[BaseException | None]: ... def request_code_actions_async( self, @@ -845,7 +856,17 @@ def request_code_actions_async( ) -> Promise[list[Command | CodeAction] | Error | None]: ... - def do_code_lenses_async(self, view: sublime.View) -> None: + async def request_code_actions( + self, + view: sublime.View, + region: sublime.Region, + diagnostics: list[Diagnostic], + kinds: list[str | CodeActionKind] | None = ..., + trigger_kind: CodeActionTriggerKind = ... + ) -> list[Command | CodeAction] | Error | None: + ... + + async def do_code_lenses(self, view: sublime.View) -> None: ... def set_pending_refresh(self, flags: RequestFlags) -> None: @@ -877,11 +898,11 @@ def session_views_async(self) -> list[SessionViewProtocol]: raise NotImplementedError @abstractmethod - def purge_changes_async(self) -> None: + def purge_changes(self) -> asyncio.Future[list[BaseException | None]]: raise NotImplementedError @abstractmethod - def trigger_on_pre_save_async(self) -> None: + def trigger_on_pre_save(self) -> asyncio.Future[list[BaseException | None]]: raise NotImplementedError @abstractmethod @@ -889,7 +910,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 @@ -911,21 +932,21 @@ def get_uri(self) -> DocumentUri: raise NotImplementedError @overload - def do_signature_help_async( + async def do_signature_help( self, trigger_kind: Literal[SignatureHelpTriggerKind.TriggerCharacter], trigger_char: str ) -> None: ... @overload - def do_signature_help_async( + async def do_signature_help( self, trigger_kind: Literal[SignatureHelpTriggerKind.Invoked, SignatureHelpTriggerKind.ContentChange], trigger_char: None = None ) -> None: ... @abstractmethod - def do_signature_help_async(self, trigger_kind: SignatureHelpTriggerKind, trigger_char: str | None = None) -> None: + async def do_signature_help(self, trigger_kind: SignatureHelpTriggerKind, trigger_char: str | None = None) -> None: raise NotImplementedError @abstractmethod @@ -988,6 +1009,74 @@ def incoming_notification(self, method: str, params: Any, unhandled: bool) -> No pass +class RequestController: + """Controller for a pending request.""" + + def __init__(self, req_id: int, session: Session) -> None: + self._id = req_id + self._weaksession = weakref.ref(session) + + async def cancel(self) -> int | None: + """Cancel this request. Return the request ID.""" + if self._id is not None: + if session := self._weaksession(): + req_id = self._id + self._id = None + await session.cancel_request(req_id) + return req_id + return None + + @property + def id(self) -> int: + """The request ID. If the request was cancelled, raises asyncio.CancelledError.""" + if self._id is not None: + return self._id + raise asyncio.CancelledError + + @property + def cancelled(self) -> bool: + """Whether the request was cancelled.""" + return self._id is None + + +class CancellableRequest(RequestController, Generic[R]): + """A request that is in flight. The result can be awaited.""" + + _future: asyncio.Future[R | Error] + + def __init__(self, future: asyncio.Future[R | Error], req_id: int, session: Session) -> None: + """ + Create a new instance of this class. + + Instances should never be created manually. The factory method for creating these objects is Session.request. + """ + super().__init__(req_id, session) + self._future = future + + def add_done_callback(self, f: Callable[[asyncio.Future[R | Error]], object]) -> None: + """Add a callback to be run when the request completes.""" + self._future.add_done_callback(f) + + async def _run(self) -> R | Error: + try: + return await self._future + except asyncio.CancelledError: + # When the await was cancelled by the user, cancel the request. Shield it from itself being cancelled. + await asyncio.shield(self.cancel()) + # And then let the cancellation bubble up. + raise + + def __await__(self) -> Generator[Any, None, R | Error]: + """ + 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 object of type protocol.Error is returned. + When the coroutine awaiting the request is cancelled (using task.cancel() or something similar), + the cancellation is held off for a bit in order to cancel the request server-side. + """ + return self._run().__await__() + + def print_to_status_bar(error: ResponseError) -> None: sublime.status_message(error["message"]) @@ -1031,7 +1120,13 @@ 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): + + _FILE_DELETED_MAX_CHECK_ATTEMPTS = 40 + """ + Number of times to sleep for 100ms and wait for a file/folder to be actually deleted during a CreateFile, DeleteFile + or RenameFile document change. + """ def __init__(self, manager: Manager, logger: Logger, workspace_folders: list[WorkspaceFolder], config: ClientConfig, plugin_class: type[AbstractPlugin | LspPlugin] | None, @@ -1049,11 +1144,9 @@ 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, RequestController | 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._variables: dict[str, str] = {} self._workspace_folders = workspace_folders @@ -1069,6 +1162,10 @@ 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._maybe_end_task: asyncio.Task | None = None + # TODO: Remove the below field when the deprecated methods + # send_request_async/send_request/send_request_task/send_request_task_2 have been removed. + self._threading_condition = threading.Condition() super().__init__() # TODO: Create an assurance that the API doesn't change here as it can be used by plugins. @@ -1087,11 +1184,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 - debounced(self.end_async, 3000, lambda: self._views_opened == current_count, async_thread=True) + 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: + exceptions_log(f"Exception while stopping {self.config.name}", await self.end()) + self._maybe_end_task = None + + 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.""" @@ -1134,7 +1253,7 @@ 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.""" + """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: @@ -1284,24 +1403,22 @@ def update_folders(self, folders: list[WorkspaceFolder]) -> None: else: self._workspace_folders = folders[:1] - def initialize_async( + async def initialize( self, variables: dict[str, str], working_directory: str | None, - transport: TransportWrapper, - init_callback: InitCallback - ) -> None: + transport: TransportWrapper + ) -> InitializeResult | Error: if self._plugin_class and issubclass(self._plugin_class, LspPlugin): self._plugin = self._plugin_class(weakref.ref(self)) self.transport = transport self.working_directory = working_directory self._variables = variables - params = get_initialize_params(self._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) - - def _handle_initialize_success(self, result: InitializeResult) -> None: + params = get_initialize_params(variables, self._workspace_folders, self.config) + result = await self.request(Request.initialize(params)) + if isinstance(result, Error): + await self.end() # ignore exceptions + return result capabilities = result['capabilities'] self.capabilities.assign(capabilities) if self._workspace_folders and not self._supports_workspace_folders(): @@ -1314,15 +1431,15 @@ def _handle_initialize_success(self, result: InitializeResult) -> None: # Handle it now and use fake request ID since it shouldn't matter. if issubclass(self._plugin_class, AbstractPlugin): self._plugin = self._plugin_class(weakref.ref(self)) - self._plugin.on_server_response_async('initialize', Response[InitializeResult](-1, result)) - self.send_notification(Notification.initialized()) + self._plugin.on_server_response_async('initialize', Response(-1, result)) + await self.notify(Notification.initialized()) if self._plugin and isinstance(self._plugin, LspPlugin): - self._plugin.on_initialized_async() + await self._plugin.on_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'): @@ -1335,15 +1452,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() + asyncio.get_running_loop().call_soon(self.do_workspace_diagnostics_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')) @@ -1377,46 +1487,46 @@ def _get_resolved_settings(self) -> dict[str, Any]: self._plugin.on_settings_changed(self.config.settings) return self.config.settings.get_resolved(self._variables) - def execute_command( - self, command: ExecuteCommandParams, *, progress: bool = False, view: sublime.View | None = None, + async def run_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.""" + ) -> LSPAny | Error: + """Run a command from the asyncio thread.""" command_name = command['command'] if self._plugin: if isinstance(self._plugin, LspPlugin): if command_handler := self._plugin.get_command_handler(command_name): - return command_handler(command.get('arguments')) + 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 promise - resolve(None) + return cast("LSPAny", await promise) + promise.resolve(None) # 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: - 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) - return Promise.resolve(None) + session_view = self.session_view_for_view_async(view) + if not session_view: + return None + listener = session_view.listener() + if not listener: + return None + await listener.do_signature_help(SignatureHelpTriggerKind.Invoked) + 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: 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( @@ -1424,29 +1534,41 @@ def run_async() -> None: key=lambda location: ( normalize_uri(location['uri']) != view_uri, location['uri'], - Point.from_lsp(location['range']['start']) - ) + Point.from_lsp(location['range']['start']), + ), ) 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.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 + try: + return await future + finally: + self._is_executing_refactoring_command = False + return await future - def _reset_is_executing_refactoring_command(self) -> None: - self._is_executing_refactoring_command = False + @deprecated("use Session.run_command instead") + def execute_command( + self, + command: ExecuteCommandParams, + *, + progress: bool = False, + view: sublime.View | None = None, + is_refactoring: bool = False, + ) -> Promise[LSPAny | Error]: + return self.create_task_and_wrap_in_promise( + self.run_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) 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]: + ) -> LSPAny | Error: command = code_action.get("command") if isinstance(command, str): code_action = cast('Command', code_action) @@ -1456,138 +1578,148 @@ 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.run_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_or_error = await self._maybe_resolve_code_action(code_action, view) + if isinstance(code_action_or_error, Error): + return code_action_or_error + return await self._apply_code_action(code_action_or_error, view) - def try_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] | None: + ) -> sublime.View | None: + """ + Try to open a URI. + + - If the URI has the file: scheme, opens the file in a tab. + - Otherwise, if the URI has the res: scheme, opens the Sublime resource file in a tab. + - Otherwise, if the URI has the untitled: scheme, opens a scratch tab. + - Otherwise, if the URI has a scheme supported by the language server, then asks the language server for the + content. + - Otherwise, if there's a plugin attached, delegates to the plugin. + """ scheme, _ = parse_uri(uri) if scheme == '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): 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 scheme == 'res': - return self._open_res_uri_async(uri, r, group) + return await self._open_res_uri(uri, r, group) if scheme == 'untitled': # VSCode specific URI scheme for unsaved buffers - flags &= sublime.NewFileFlags.TRANSIENT | sublime.NewFileFlags.ADD_TO_SELECTION - 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: - self.window.focus_view(view) - return Promise.resolve(view) + + def open_untitled_buffer(flags: sublime.NewFileFlags) -> sublime.View: + flags &= sublime.NewFileFlags.TRANSIENT | sublime.NewFileFlags.ADD_TO_SELECTION + 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: + self.window.focus_view(view) + return view + view = self.window.new_file(flags) + view.set_scratch(True) + view.set_name(name) + return view view = self.window.new_file(flags) view.set_scratch(True) - view.set_name(name) - return Promise.resolve(view) - view = self.window.new_file(flags) - view.set_scratch(True) - return Promise.resolve(view) + return view + + return await run_on_main_thread(open_untitled_buffer, flags) if scheme in self.get_capability('workspace.textDocumentContent.schemes', []): - return self.send_request_task(Request('workspace/textDocumentContent', {'uri': uri})) \ - .then(lambda response: self._on_text_document_content_async(response, uri, flags, group)) \ - .then(lambda view: self._on_view_for_uri_opened(view, uri, r) if view else None) + title = urlparse(uri).path.split('/')[-1] + response: TextDocumentContentResult | Error = await self.request( + Request('workspace/textDocumentContent', {'uri': uri}) + ) + if isinstance(response, Error): + # TODO: Handle error. + return None + content = response['text'].replace('\r', '') + syntax = self.config.syntax_map.get(parse_uri(uri)[0], '') + return self._on_view_for_uri_opened( + await self.open_scratch_buffer(title, content, syntax, flags, group), uri, r + ) # There is no pre-existing session-buffer, so we have to go through the plugin's URI handler. if self._plugin: if isinstance(self._plugin, LspPlugin): if handler := self._plugin.get_uri_handler(scheme): - return handler(uri, flags).then(lambda sheet: self._on_sheet_for_uri_opened(sheet, uri, r)) + sheet = await handler(uri, flags) + return self._on_sheet_for_uri_opened(sheet, uri, r) else: - return self._open_uri_with_plugin_async(self._plugin, uri, r, flags, group) + return await self._open_uri_with_plugin(self._plugin, uri, r, flags, group) return None - def open_uri_async( - 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 - - 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]: - 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)) - return result[0] + ) -> sublime.View | None: + view = await open_file(self.window, uri, flags, group) + # Note: opening a JPEG/PNG returns a sublime.View, but view.is_valid() returns False in that case. + if view and r and view.is_valid(): + center_selection(view, r) + return view - 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: + 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)) - 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 result[0] + return await run_on_main_thread(continue_on_main_thread) - 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 | None: # I cannot type-hint an unpacked tuple pair: PackagedTask[tuple[str, str, str]] = Promise.packaged_task() promise, resolve = pair # It'd be nice to have automatic tuple unpacking continuations callback = lambda a, b, c: resolve((a or 'untitled', b, c)) # noqa: E731 if plugin.on_open_uri_async(uri, callback): - return promise.then(lambda tup: self.open_scratch_buffer(*tup, flags, group)) \ - .then(lambda view: self._on_view_for_uri_opened(view, uri, r)) + title, content, syntax = await promise + view = await self.open_scratch_buffer(title, content, syntax, flags, group) + return self._on_view_for_uri_opened(view, uri, r) # resolve unused promise resolve(('', '', '')) return None - def open_scratch_buffer( + async def open_scratch_buffer( self, title: str, content: str, syntax: str, flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE, group: int = -1, - ) -> Promise[sublime.View]: - task: PackagedTask[sublime.View] = Promise.packaged_task() - promise, resolve = task + ) -> sublime.View: - def continue_on_main_thread() -> None: + def continue_on_main_thread() -> sublime.View: if group > -1: self.window.focus_group(group) view = self.window.new_file(syntax=syntax, flags=flags) @@ -1597,10 +1729,9 @@ 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 - sublime.set_timeout(continue_on_main_thread) - return promise + return await run_on_main_thread(continue_on_main_thread) def _on_sheet_for_uri_opened( self, sheet: sublime.Sheet | None, uri: DocumentUri, r: Range | None @@ -1614,57 +1745,26 @@ def _on_view_for_uri_opened(self, view: sublime.View, uri: DocumentUri, r: Range center_selection(view, r) return view - def _on_text_document_content_async( - self, response: TextDocumentContentResult | Error, uri: DocumentUri, flags: sublime.NewFileFlags, group: int - ) -> Promise[sublime.View | None]: - if isinstance(response, Error): - return Promise.resolve(None) - title = urlparse(uri).path.split('/')[-1] - content = response['text'].replace('\r', '') - syntax = self.config.syntax_map.get(parse_uri(uri)[0], '') - return self.open_scratch_buffer(title, content, syntax, flags, group) # pyright: ignore[reportReturnType] - - def _on_text_document_content_refreshed(self, view: sublime.View, new_content: str) -> None: - content_region = entire_content_region(view) - selection_region = first_selection_region(view) - selection = view.sel() - selection.add(content_region) - with mutable(view): - view.run_command('insert', {'characters': new_content}) - # Try to restore original selection if possible - if selection_region is not None and selection_region.begin() < view.size(): - selection.clear() - selection.add(selection_region) - - def _on_text_document_content_refreshed_async( - self, view: sublime.View, response: TextDocumentContentResult - ) -> None: - if not view.is_valid(): - return - new_content = response['text'].replace('\r', '') - if new_content != entire_content(view): - sublime.set_timeout(lambda: self._on_text_document_content_refreshed(view, new_content)) - - 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: + async def notify_plugin_on_session_buffer_change(self, session_buffer: SessionBufferProtocol) -> None: if not self._plugin: return if isinstance(self._plugin, LspPlugin): - self._plugin.on_text_changed_async(session_buffer) + await self._plugin.on_text_changed(session_buffer) else: 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 | Error: if "edit" not in code_action: has_capability = self.has_capability("codeActionProvider.resolveProvider") if not has_capability and view: @@ -1672,46 +1772,34 @@ 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 | None, view: sublime.View | None) -> None: if not code_action: - return Promise.resolve(None) - 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) - command = code_action.get("command") - if command is not None: + if edit: + await self.apply_workspace_edit(edit, label=title, is_refactoring=is_refactoring) + if command := code_action.get("command"): execute_command: ExecuteCommandParams = { "command": command["command"], } 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.run_command(execute_command, progress=False, view=view, is_refactoring=is_refactoring) - def apply_document_changes_async( + async def apply_document_changes( self, document_changes: list[TextDocumentEdit | CreateFile | RenameFile | DeleteFile], change_annotations: dict[ChangeAnnotationIdentifier, ChangeAnnotation], *, label: str | None = None, - is_refactoring: bool = False - ) -> Promise[ApplyWorkspaceEditResult]: + is_refactoring: bool = False, + ) -> ApplyWorkspaceEditResult: created_files: list[FileCreate] = [] renamed_files: list[FileRename] = [] deleted_files: list[FileDelete] = [] @@ -1719,15 +1807,15 @@ def apply_document_changes_async( selected_sheets = self.window.selected_sheets() auto_save = userprefs().refactoring_auto_save if is_refactoring else 'never' index = 0 # Assuming 0-based indexing for the ApplyWorkspaceEditResult.faildedChange value - promise = self._apply_document_changes_recursive_async( - document_changes, change_annotations, created_files, renamed_files, deleted_files, index, label, auto_save) - promise \ - .then(lambda _: self._set_selected_sheets(selected_sheets)) \ - .then(lambda _: self._set_focused_sheet(active_sheet)) \ - .then(lambda _: self._notify_after_resource_operations(created_files, renamed_files, deleted_files)) - return promise - - def _apply_document_changes_recursive_async( + result = await self._apply_document_changes_recursive( + document_changes, change_annotations, created_files, renamed_files, deleted_files, index, label, auto_save + ) + self._set_selected_sheets(selected_sheets) + self._set_focused_sheet(active_sheet) + self._notify_after_resource_operations(created_files, renamed_files, deleted_files) + return result + + async def _apply_document_changes_recursive( self, document_changes: list[TextDocumentEdit | CreateFile | RenameFile | DeleteFile], change_annotations: dict[ChangeAnnotationIdentifier, ChangeAnnotation], @@ -1737,19 +1825,19 @@ def _apply_document_changes_recursive_async( index: int, label: str | None, auto_save: str - ) -> Promise[ApplyWorkspaceEditResult]: + ) -> ApplyWorkspaceEditResult: - def apply_text_document_edit( + async def apply_text_document_edit( view: sublime.View | None, uri: DocumentUri, edits: list[TextEdit | AnnotatedTextEdit | SnippetTextEdit], version: int | None, view_state_actions: ViewStateActions - ) -> Promise[str | None]: + ) -> str | None: if not view: - return Promise.resolve(f'Failed to open URI {uri}') + return f'Failed to open URI {uri}' if version is not None and version != (change_count := view.change_count()): - return Promise.resolve(f'Document version for URI {uri} is {change_count}, but required {version}') + return f'Document version for URI {uri} is {change_count}, but required {version}' for edit in edits: # Use more specific label for this particular TextDocumentEdit if available if annotation_id := edit.get('annotationId'): @@ -1758,52 +1846,61 @@ def apply_text_document_edit( else: edit_label = label view.run_command('lsp_apply_text_document_edit', {'edits': edits, 'label': edit_label}) - promise = Promise(lambda resolve: sublime.set_timeout_async(lambda: resolve(None))) if view and view_state_actions: - return promise.then(lambda _: self._set_view_state(view_state_actions, view)) # pyright: ignore[reportReturnType] - return promise + await self._set_view_state(view_state_actions, view) + return None - def create_file(path: str) -> Promise[str | None]: + def create_file(path: str) -> str | None: try: Path(path).open('x', encoding='utf-8').close() except (FileExistsError, OSError) as ex: - return Promise.resolve(str(ex)) + return str(ex) created_files.append({'uri': filename_to_uri(path)}) - return Promise.resolve(None) + return None - def rename_file(old_path: str, new_path: str) -> Promise[str | None]: + def rename_file(old_path: str, new_path: str) -> str | None: view = self.window.find_open_file(old_path) if os.path.isfile(old_path) else None try: Path(old_path).rename(new_path) except (FileExistsError, IsADirectoryError, NotADirectoryError, OSError) as ex: - return Promise.resolve(str(ex)) + return str(ex) old_uri = filename_to_uri(old_path) new_uri = filename_to_uri(new_path) if view: view.retarget(new_path) view.settings().set('lsp_uri', new_uri) renamed_files.append({'oldUri': old_uri, 'newUri': new_uri}) - return Promise.resolve(None) + return None + + async def wait_for_path_deletion(path: str) -> None: + attempts = 0 + while os.path.exists(path) and attempts < self._FILE_DELETED_MAX_CHECK_ATTEMPTS: # noqa: ASYNC240 + await asyncio.sleep(0.1) + attempts += 1 + if attempts >= self._FILE_DELETED_MAX_CHECK_ATTEMPTS and not os.path.exists(path): # noqa: ASYNC240 + raise asyncio.TimeoutError(f"Timeout waiting for deletion of {path}") - def delete_file(path: str) -> Promise[None]: + async def delete_file(path: str) -> None: # The delete_file command moves the given files into the recycle bin self.window.run_command('delete_file', {'files': [path], 'prompt': False}) - return Promise(lambda resolve: sublime.set_timeout_async(lambda: resolve(None), 1)) + # TODO: ideally we'd have a callback for 'delete_file' + await wait_for_path_deletion(path) - def delete_folder(path: str) -> Promise[None]: + async def delete_folder(path: str) -> None: # The delete_folder command moves the given folders into the recycle bin self.window.run_command('delete_folder', {'dirs': [path], 'prompt': False}) - return Promise(lambda resolve: sublime.set_timeout_async(lambda: resolve(None), 1)) + # TODO: ideally we'd have a callback for 'delete_folder' + await wait_for_path_deletion(path) - def _continue(failure_reason: str | None) -> Promise[ApplyWorkspaceEditResult]: + async def _continue(failure_reason: str | None) -> ApplyWorkspaceEditResult: if failure_reason: printf(f'Error while applying WorkspaceEdit: {failure_reason}') - return Promise.resolve({ + return { 'applied': False, 'failureReason': failure_reason, 'failedChange': index - }) - return self._apply_document_changes_recursive_async( + } + return await self._apply_document_changes_recursive( document_changes, change_annotations, created_files, @@ -1818,80 +1915,83 @@ def _continue(failure_reason: str | None) -> Promise[ApplyWorkspaceEditResult]: document_change = document_changes.pop(0) except IndexError: # All document changes were handled - return Promise.resolve({'applied': True}) + return {'applied': True} if is_text_document_edit(document_change): text_document = document_change['textDocument'] uri = text_document['uri'] version = text_document['version'] view_state_actions = self._get_view_state_actions(uri, auto_save) - return self.open_uri_async(uri).then( - lambda view: apply_text_document_edit(view, uri, document_change['edits'], version, view_state_actions) - ).then(_continue) + view = await self.open_uri(uri) + failure_reason = await apply_text_document_edit( + view, uri, document_change['edits'], version, view_state_actions) + return await _continue(failure_reason) if is_create_file(document_change): uri = document_change['uri'] options = document_change.get('options', {}) scheme, path = parse_uri(uri) if scheme != 'file': - return _continue(f'CreateFile not supported for URI {uri}') - if os.path.isfile(path): + return await _continue(f'CreateFile not supported for URI {uri}') + if os.path.isfile(path): # noqa: ASYNC240 if options.get('overwrite'): - return delete_file(path).then(lambda _: create_file(path)).then(_continue) + await delete_file(path) + return await _continue(create_file(path)) if options.get('ignoreIfExists'): - return _continue(None) - return _continue(f'CreateFile failed because a file already exists at target {uri}') - if os.path.isdir(path): + return await _continue(None) + return await _continue(f'CreateFile failed because a file already exists at target {uri}') + if os.path.isdir(path): # noqa: ASYNC240 # Don't allow to overwrite entire folders, even if the CreateFileOptions.overwrite flag is set - return _continue(f'CreateFile failed because a folder already exists at target {uri}') - return create_file(path).then(_continue) + return await _continue(f'CreateFile failed because a folder already exists at target {uri}') + return await _continue(create_file(path)) if is_rename_file(document_change): old_uri = document_change['oldUri'] new_uri = document_change['newUri'] options = document_change.get('options', {}) old_scheme, old_path = parse_uri(old_uri) if old_scheme != 'file': - return _continue(f'RenameFile not supported for URI {old_uri}') - if not os.path.exists(old_path): - return _continue(f'RenameFile failed because {old_uri} does not exist') + return await _continue(f'RenameFile not supported for URI {old_uri}') + if not os.path.exists(old_path): # noqa: ASYNC240 + return await _continue(f'RenameFile failed because {old_uri} does not exist') new_scheme, new_path = parse_uri(new_uri) if new_scheme != 'file': - return _continue(f'RenameFile not supported for URI {new_uri}') - if os.path.isfile(new_path): - if options.get('overwrite') and os.path.isfile(old_path): - return delete_file(new_path).then(lambda _: rename_file(old_path, new_path)).then(_continue) + return await _continue(f'RenameFile not supported for URI {new_uri}') + if os.path.isfile(new_path): # noqa: ASYNC240 + if options.get('overwrite') and os.path.isfile(old_path): # noqa: ASYNC240 + await delete_file(new_path) + return await _continue(rename_file(old_path, new_path)) if options.get('ignoreIfExists'): - return _continue(None) - return _continue(f'RenameFile failed because target {new_uri} already exists') - if os.path.isdir(new_path): + return await _continue(None) + return await _continue(f'RenameFile failed because target {new_uri} already exists') + if os.path.isdir(new_path): # noqa: ASYNC240 # Don't allow to overwrite entire folders, even if the CreateFileOptions.overwrite flag is set - return _continue(f'RenameFile failed because target {new_uri} already exists') - return rename_file(old_path, new_path).then(_continue) + return await _continue(f'RenameFile failed because target {new_uri} already exists') + return await _continue(rename_file(old_path, new_path)) if is_delete_file(document_change): uri = document_change['uri'] options = document_change.get('options', {}) scheme, path = parse_uri(uri) if scheme != 'file': - return _continue(f'DeleteFile not supported for URI {uri}') - if os.path.isfile(path): + return await _continue(f'DeleteFile not supported for URI {uri}') + if os.path.isfile(path): # noqa: ASYNC240 deleted_files.append({'uri': uri}) - return delete_file(path).then(_continue) - if os.path.isdir(path): + return await _continue(await delete_file(path)) + if os.path.isdir(path): # noqa: ASYNC240 if os.listdir(path) and not options.get('recursive'): - return _continue(f'DeleteFile failed because folder {uri} is not empty') + return await _continue(f'DeleteFile failed because folder {uri} is not empty') deleted_files.append({'uri': uri}) - return delete_folder(path).then(_continue) + return await _continue(await delete_folder(path)) if options.get('ignoreIfNotExists'): - return _continue(None) - return _continue(f'DeleteFile failed because {uri} does not exist') + return await _continue(None) + return await _continue(f'DeleteFile failed because {uri} does not exist') # Should be unreachable, but must return value on all code paths to satisfy type checker - return _continue('Unknown document change type') + return await _continue('Unknown document change type') - def apply_workspace_edit_async( + async def apply_workspace_edit( self, edit: WorkspaceEdit, *, label: str | None = None, is_refactoring: bool = False - ) -> Promise[tuple[ApplyWorkspaceEditResult, WorkspaceEditSummary]]: + ) -> tuple[ApplyWorkspaceEditResult, 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 the ApplyWorkspaceEditResult and a summary of the changes in the - WorkspaceEdit. + Apply a WorkspaceEdit. + + Returns the ApplyWorkspaceEditResult and a summary of the changes in the WorkspaceEdit. """ document_changes = edit.get('documentChanges', []) if not document_changes: @@ -1917,12 +2017,31 @@ def apply_workspace_edit_async( summary['renamed_files'] += 1 elif is_delete_file(document_change): summary['deleted_files'] += 1 - return self.apply_document_changes_async( - document_changes, - change_annotations, - label=label, - is_refactoring=is_refactoring or self._is_executing_refactoring_command - ).then(lambda result: (result, summary)) + return ( + await self.apply_document_changes( + document_changes, + change_annotations, + label=label, + is_refactoring=is_refactoring or self._is_executing_refactoring_command + ), + summary + ) + + @deprecated("use Session.apply_workspace_edit instead") + def apply_workspace_edit_async( + self, edit: WorkspaceEdit, *, label: str | None = None, is_refactoring: bool = False + ) -> Promise[tuple[ApplyWorkspaceEditResult, WorkspaceEditSummary]]: + + def ignore_exception( + x: tuple[ApplyWorkspaceEditResult, WorkspaceEditSummary] | BaseException, + ) -> tuple[ApplyWorkspaceEditResult, WorkspaceEditSummary]: + if isinstance(x, BaseException): + return {"applied": False}, {"created_files": 0, "edited_files": 0, "total_changes": 0} + return x + + return self.create_task_and_wrap_in_promise( + self.apply_workspace_edit(edit, label=label, is_refactoring=is_refactoring) + ).then(ignore_exception) def _get_view_state_actions(self, uri: DocumentUri, auto_save: str) -> ViewStateActions: """ @@ -1957,21 +2076,18 @@ 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) + async def _set_view_state(self, actions: ViewStateActions, view: sublime.View) -> None: should_save = bool(actions & ViewStateActions.SAVE) should_close = bool(actions & ViewStateActions.CLOSE) if should_save and view.is_dirty(): # The save operation must be blocking in case the tab should be closed afterwards view.run_command('save', {'async': not should_close, 'quiet': True}) # Allow async thread to process save notifications before closing the file or the method returns. - promise = Promise(lambda resolve: sublime.set_timeout_async(lambda: resolve(None))) - - def handle_close() -> None: - if should_close and not view.is_dirty(): - view.close() - - return promise.then(lambda _: handle_close()) + await tick() + if should_close and not view.is_dirty(): + future = asyncio.get_running_loop().create_future() + view.close(partial(run_on_asyncio_thread, future.set_result)) # type: ignore + await future def _set_selected_sheets(self, sheets: list[sublime.Sheet]) -> None: if len(sheets) > 1 and len(self.window.selected_sheets()) != len(sheets): @@ -2032,9 +2148,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) + self.create_task(self._do_workspace_diagnostics_async(identifier)) - def _do_workspace_diagnostics_async(self, identifier: DiagnosticsIdentifier) -> None: + async def _do_workspace_diagnostics_async(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 @@ -2042,18 +2158,35 @@ 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( + self.workspace_diagnostics_pending_responses[identifier] = req = self.request( 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) + on_partial_result=partial( + self._on_workspace_diagnostics_async, identifier, reset_pending_response=False + ), + ), ) + response = await req + if isinstance(response, Error): + if ( + response.code == LSPErrorCodes.ServerCancelled + and is_diagnostic_server_cancellation_data(response.data) + and response.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. + await asyncio.sleep(WORKSPACE_DIAGNOSTICS_RETRIGGER_DELAY / 1000.0) + self.create_task(self._do_workspace_diagnostics_async(identifier)) + return + self.workspace_diagnostics_pending_responses[identifier] = None + return + self._on_workspace_diagnostics_async(identifier, response) def _on_workspace_diagnostics_async( self, identifier: DiagnosticsIdentifier, - response: WorkspaceDiagnosticReport, + response: WorkspaceDiagnosticReportPartialResult | WorkspaceDiagnosticReport, *, reset_pending_response: bool = True ) -> None: @@ -2070,20 +2203,6 @@ def _on_workspace_diagnostics_async( diagnostics = report['items'] if is_workspace_full_document_diagnostic_report(report) else None self.handle_diagnostics_async(uri, identifier, version, diagnostics) - 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 - # --- workspace/didChangeConfiguration ----------------------------------------------------------------------------- def on_server_settings_changed(self, settings: DottedDict) -> None: @@ -2097,10 +2216,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: @@ -2113,11 +2232,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: @@ -2126,83 +2245,117 @@ 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._variables)) + return sublime.expand_variables(items, self._variables) @request_handler('workspace/applyEdit') - def on_workspace_apply_edit(self, params: ApplyWorkspaceEditParams) -> Promise[ApplyWorkspaceEditResult]: - is_refactoring = metadata.get('isRefactoring', False) if (metadata := params.get('metadata')) else False - return self.apply_workspace_edit_async( - params['edit'], label=params.get('label'), is_refactoring=is_refactoring - ).then(itemgetter(0)) + async def on_workspace_apply_edit(self, params: ApplyWorkspaceEditParams) -> ApplyWorkspaceEditResult: + return ( + await self.apply_workspace_edit( + params['edit'], + label=params.get('label'), + is_refactoring=metadata.get('isRefactoring', False) if (metadata := params.get('metadata')) else False, + ) + )[0] @request_handler('workspace/codeLens/refresh') - def on_workspace_code_lens_refresh(self, _: None) -> tuple[Promise[None], PostResponseCallback]: + async def on_workspace_code_lens_refresh(self, _: None) -> tuple[None, PostResponseCallback]: 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.create_task(session_buffer.do_code_lenses(session_view.view)) for session_buffer in not_visible_session_buffers: session_buffer.set_pending_refresh(RequestFlags.CODE_LENS) - return (Promise.resolve(None), continue_after_response) + return (None, continue_after_response) @request_handler('workspace/semanticTokens/refresh') - def on_workspace_semantic_tokens_refresh(self, _: None) -> tuple[Promise[None], PostResponseCallback]: + async def on_workspace_semantic_tokens_refresh(self, _: None) -> tuple[None, PostResponseCallback]: 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: if session_view.get_request_flags() & RequestFlags.SEMANTIC_TOKENS: - session_buffer.do_semantic_tokens_async(session_view.view) + session_buffer.create_task(session_buffer.do_semantic_tokens(session_view.view)) else: session_buffer.set_pending_refresh(RequestFlags.SEMANTIC_TOKENS) for session_buffer in not_visible_session_buffers: session_buffer.set_pending_refresh(RequestFlags.SEMANTIC_TOKENS) - return (Promise.resolve(None), continue_after_response) + return (None, continue_after_response) @request_handler('workspace/inlayHint/refresh') - def on_workspace_inlay_hint_refresh(self, _: None) -> tuple[Promise[None], PostResponseCallback]: + async def on_workspace_inlay_hint_refresh(self, _: None) -> tuple[None, PostResponseCallback]: 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: if session_view.get_request_flags() & RequestFlags.INLAY_HINT: - session_buffer.do_inlay_hints_async(session_view.view) + session_buffer.create_task(session_buffer.do_inlay_hints(session_view.view)) else: session_buffer.set_pending_refresh(RequestFlags.INLAY_HINT) for session_buffer in not_visible_session_buffers: session_buffer.set_pending_refresh(RequestFlags.INLAY_HINT) - return (Promise.resolve(None), continue_after_response) + return (None, continue_after_response) @request_handler('workspace/diagnostic/refresh') - def on_workspace_diagnostic_refresh(self, _: None) -> tuple[Promise[None], PostResponseCallback]: - return (Promise.resolve(None), self._refresh_diagnostics) + async def on_workspace_diagnostic_refresh(self, _: None) -> tuple[None, PostResponseCallback]: + return (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.create_task( + 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) @request_handler('workspace/textDocumentContent/refresh') - def on_workspace_text_document_content_refresh(self, params: TextDocumentContentRefreshParams) -> Promise[None]: - sublime.set_timeout_async(lambda: self._refresh_text_document_content_async(params['uri'])) - return Promise.resolve(None) + async def on_workspace_text_document_content_refresh(self, params: TextDocumentContentRefreshParams + ) -> tuple[None, PostResponseCallback]: + + def continue_after_response(uri: DocumentUri) -> None: + self.create_task(self._refresh_text_document_content(uri)) - def _refresh_text_document_content_async(self, uri: DocumentUri) -> None: + return (None, partial(continue_after_response, params['uri'])) + + async def _refresh_text_document_content(self, uri: DocumentUri) -> None: for view in self.window.views(): try: - if uri_from_view(view) == uri: - request = Request('workspace/textDocumentContent', {'uri': uri}) - self.send_request_async(request, partial(self._on_text_document_content_refreshed_async, view)) - break + candidate_uri = uri_from_view(view) except MissingUriError: continue + if candidate_uri != uri: + continue + response: TextDocumentContentResult | Error = await self.request( + Request('workspace/textDocumentContent', {'uri': uri}) + ) + if isinstance(response, Error): + sublime.status_message(f"Error getting content: {response}") + break + new_content = response['text'].replace('\r', '') + if new_content == entire_content(view): + break + + def continue_on_main_thread(view: sublime.View, new_content: str) -> None: + if not view.is_valid(): + return + content_region = entire_content_region(view) + selection_region = first_selection_region(view) + selection = view.sel() + selection.add(content_region) + with mutable(view): + view.run_command('insert', {'characters': new_content}) + # Try to restore original selection if possible + if selection_region is not None and selection_region.begin() < view.size(): + selection.clear() + selection.add(selection_region) + + await run_on_main_thread(partial(continue_on_main_thread, view, new_content)) + break @notification_handler('textDocument/publishDiagnostics') def on_text_document_publish_diagnostics(self, params: PublishDiagnosticsParams) -> None: @@ -2238,7 +2391,7 @@ def clear_diagnostics_for_uri(self, uri: DocumentUri) -> None: mgr.on_diagnostics_updated() @request_handler('client/registerCapability') - def on_client_register_capability(self, params: RegistrationParams) -> tuple[Promise[None], PostResponseCallback]: + async def on_client_register_capability(self, params: RegistrationParams) -> tuple[None, PostResponseCallback]: new_diagnostics_provider = False new_workspace_diagnostics_provider = False for registration in params["registrations"]: @@ -2273,7 +2426,7 @@ def on_client_register_capability(self, params: RegistrationParams) -> tuple[Pro 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']) @@ -2284,10 +2437,10 @@ def continue_after_response() -> None: if new_workspace_diagnostics_provider: self.do_workspace_diagnostics_async() - return (Promise.resolve(None), continue_after_response) + return (None, 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"] @@ -2306,7 +2459,6 @@ def on_client_unregister_capability(self, params: UnregistrationParams) -> Promi if isinstance(discarded, dict): for sv in self.session_views_async(): 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: @@ -2338,27 +2490,17 @@ 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 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))) + return {"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) + result = await self.open_uri(uri, params.get("selection")) + return {"success": result is not None} @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: if request.view: @@ -2437,16 +2579,16 @@ 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) -> 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: @@ -2456,125 +2598,216 @@ 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) - - def shutdown_session_view_async(self, session_view: SessionViewProtocol) -> None: + 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 + + 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() - 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 + for _request, _result_handler, error_handler in self._response_handlers.values(): + error_handler(Error(ErrorCodes.InternalError, "transport closed").to_lsp()) self._response_handlers.clear() if self._plugin: - self._plugin.on_session_end_async(exit_code, exception) + if isinstance(self._plugin, LspPlugin): + await self._plugin.on_session_end(exit_code, exception) + else: + 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 ---------------------------------------------------------------------------------------- - def send_request_async( - self, - request: Request[P_contra, 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.""" + def request(self, r: Request[P_contra, R]) -> CancellableRequest[R]: + """ + Make a request to the language server. + + You must call this method from the asyncio thread. + + ```py + result = await session.request(Request(...)) + if isinstance(result, Error): + print(result.code, result.message) + else: + print(result) + ``` + """ 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) + loop = asyncio.get_running_loop() + future = loop.create_future() + result = CancellableRequest(future, request_id, self) + 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) + + def on_result(result: R) -> None: + # Future may have been cancelled. + if not future.done(): + future.set_result(result) + + def on_error(error: ResponseError) -> None: + # Future may have been cancelled. + if not future.done(): + future.set_result(Error.from_lsp(error)) + + self._response_handlers[request_id] = (r, on_result, on_error) + self._invoke_views(r, "on_request_started_async", result, 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_contra', client_request['params']) - self._logger.outgoing_request(request_id, request.method, request.params) - self.send_payload(request.to_payload(request_id)) - return 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_contra', 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 + + @deprecated("use Session.request instead") + def send_request_async( + self, + request: Request[P_contra, R], + on_result: Callable[[R], None], + on_error: Callable[[ResponseError], None] | None = None + ) -> int: + """You can call this method from any thread. Callbacks will run in the asyncio thread.""" + + def do_request() -> int: + result = self.request(request) + def on_done(future: asyncio.Future[R | Error]) -> None: + if future.cancelled(): + return + if ex := future.exception(): + exception_log(f"Unhandled exception during request {request.method}", ex) + return + result = future.result() + if isinstance(result, Error): + if callable(on_error): + on_error(result.to_lsp()) + else: + exception_log("Response error is ignored", result) + else: + on_result(result) + + result._future.add_done_callback(on_done) + return result.id + + # Quite an involved method body, but it's necessary as this method may be called from sublime's worker thread in + # some LSP-* packages (and in this package itself are also still call sites, although those call sites guarantee + # it's invoked from the asyncio thread). + try: + # Check if we're already running inside the asyncio thread. If not, this call will throw RuntimeError. + asyncio.get_running_loop() + # We're already running inside the asyncio thread, so we can just go ahead and do the request directly. + return do_request() + except RuntimeError: + # We're not running in the asyncio thread, so we have to use a complicated threading condition variable. + pass + request_id: int | None = None + + def set_request_id() -> None: + nonlocal request_id + with self._threading_condition: + try: + request_id = do_request() + except: # ruff: ignore[bare-except] + request_id = -1 + finally: + self._threading_condition.notify() + + with self._threading_condition: + run_on_asyncio_thread(set_request_id) + self._threading_condition.wait_for(lambda: request_id is not None) + return request_id # pyright: ignore[reportReturnType] + + @deprecated("use Session.request instead") def send_request( - self, - request: Request[P_contra, R], - on_result: Callable[[R], None], - on_error: Callable[[ResponseError], None] | None = None, + self, + request: Request[P_contra, 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.""" - sublime.set_timeout_async(partial(self.send_request_async, request, on_result, on_error)) + """You can call this method from any thread. Callbacks will run in the asyncio thread.""" + run_on_asyncio_thread(lambda: self.send_request_async(request, on_result, on_error)) + @deprecated("use Session.request instead") def send_request_task(self, request: Request[P_contra, 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))) - return promise + async def do() -> R | Error: + return await self.request(request) + + return self.create_task_and_wrap_in_promise(do()) + + @deprecated("use Session.request instead") def send_request_task_2(self, request: Request[P_contra, R]) -> tuple[Promise[R | Error], int]: task: PackagedTask[R | Error] = Promise.packaged_task() promise, resolver = task request_id = self.send_request_async(request, resolver, lambda x: resolver(Error.from_lsp(x))) return (promise, request_id) - def cancel_request_async(self, request_id: int) -> None: + async def cancel_request(self, request_id: int) -> None: if request_id in self._response_handlers: - 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._response_handlers[request_id] = (request, lambda *args: None, lambda *args: None) + await self.notify(Notification("$/cancelRequest", {"id": request_id})) + if tup := self._response_handlers.get(request_id): + self._invoke_views(tup[0], "on_request_canceled_async", request_id) - def send_notification(self, notification: Notification[P_contra]) -> None: + async def notify(self, notification: Notification[P_contra]) -> 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: client_notification = cast('ClientNotification', cast('object', {'method': notification.method, 'params': notification.params})) - self._plugin.on_pre_send_notification_async(client_notification) + await self._plugin.on_pre_send_notification(client_notification) notification.params = cast('P_contra', client_notification['params']) self._logger.outgoing_notification(notification.method, notification.params) - self.send_payload(notification.to_payload()) + await self.send_payload(notification.to_payload()) + + def send_notification_async(self, notification: Notification[P_contra]) -> 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_response(self, response: Response[R]) -> None: + def send_notification(self, notification: Notification[P_contra]) -> 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[R]) -> None: self._logger.outgoing_response(response.request_id, response.result) - self.send_payload(response.to_payload()) + await self.send_payload(response.to_payload()) if response.post_response_callback: response.post_response_callback() - 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 - 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 - def deduce_payload( + async def deduce_payload( self, payload: JSONRPCMessage ) -> tuple[Callable | None, Any, str | int | None, str | None, str | None]: @@ -2586,7 +2819,7 @@ 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: @@ -2597,7 +2830,7 @@ def deduce_payload( elif self._plugin: server_notification = cast('ServerNotification', cast('object', {'method': method, 'params': result})) - self._plugin.on_server_notification_async(server_notification) + await self._plugin.on_server_notification(server_notification) return res elif "id" in payload: response_id = payload["id"] @@ -2613,51 +2846,50 @@ def deduce_payload( else: server_response = cast('ServerResponse', cast('object', {'method': method, 'result': response.result})) - self._plugin.on_server_response_async(server_response) + await self._plugin.on_server_response(server_response) response.result = server_response['result'] 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: if req_id is None: - # notification or response + # server notification or (response to) client request handler(result) else: - # request + # server request try: - result_promise = cast('Promise[Response[Any]] | None', handler(result, req_id)) + await self.send_response( + await self._handle_plugin_on_pre_send_response_async( + method, result, await handler(result, req_id) + ) + ) except Error as err: - self.send_error_response(req_id, err) - return + await self.send_error_response(req_id, err) 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(lambda r: self._handle_plugin_on_pre_send_response_async(method, result, r)) \ - .then(self.send_response) + else: + debug("no handler found for payload:", payload) - def _handle_plugin_on_pre_send_response_async( + async def _handle_plugin_on_pre_send_response_async( self, method: str | None, params: Any, response: Response[Any] ) -> Response[Any]: if method and isinstance(self._plugin, LspPlugin): obj = cast('ClientResponse', {'method': method, 'params': params, 'result': response.result}) - self._plugin.on_pre_send_response_async(obj) + await self._plugin.on_pre_send_response(obj) return response 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) diff --git a/plugin/core/signature_help.py b/plugin/core/signature_help.py index a9d2ad676..105b4ac96 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 run_coroutine 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)) + run_coroutine(listener.do_signature_help(SignatureHelpTriggerKind.Invoked)) class SigHelp: diff --git a/plugin/core/transports.py b/plugin/core/transports.py index 40b11c416..5050c0e5b 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -1,36 +1,30 @@ from __future__ import annotations +from .aio import TaskContainer 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 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 asyncio.subprocess import contextlib -import http.client import json import os import shutil import socket import sublime import subprocess -import threading import time import weakref if TYPE_CHECKING: from .protocol import JSONRPCMessage - from io import BufferedIOBase try: import orjson @@ -48,7 +42,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( @@ -56,6 +50,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() @@ -68,7 +66,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, @@ -76,6 +74,7 @@ def start( variables: dict[str, str], callbacks: TransportCallbacks, ) -> TransportWrapper: + """Start a communication channel with the language server.""" raise NotImplementedError @@ -87,7 +86,7 @@ class StdioTransportConfig(TransportConfig): """ @override - def start( + async def start( self, command: list[str] | None, env: dict[str, str] | None, @@ -97,7 +96,8 @@ 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( + launch = TransportConfig.resolve_launch_config(command, env, variables) + process = await launch.start( cwd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, @@ -107,8 +107,9 @@ 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, + process_args=launch.command, error_reader=ErrorReader(callbacks, process.stderr), ) @@ -129,7 +130,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, @@ -138,12 +139,14 @@ def start( callbacks: TransportCallbacks, ) -> TransportWrapper: port = _add_and_resolve_port_variable(variables, self._port) + launch: LaunchConfig | None = None if command: - process = 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.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') @@ -151,27 +154,37 @@ def start( else: process = None error_reader = None - return TransportWrapper( - callback_object=callbacks, - transport=SocketTransport(encode_json, decode_json, self._connect(port)), - 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: + current_time = start_time + delta = 0 + while delta < TCP_CONNECT_TIMEOUT: + time_left = TCP_CONNECT_TIMEOUT - delta try: - return socket.create_connection(('localhost', port)) + 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), + process=process, + process_args=launch.command if launch else None, + error_reader=error_reader, + ) except ConnectionRefusedError: - pass - raise RuntimeError("failed to connect") + # 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): """ 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. """ @@ -182,7 +195,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, @@ -194,149 +207,152 @@ 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, command, self.error_reader) + self.cv.notify() + + callback = ClientConnectedCallback() + async with callback.cv: + 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( + 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 ------------------------------------------------------------------------------------------------------- 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: ... 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 @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 write_bytes(self, payload: bytes) -> None: + async def write_bytes(self, payload: bytes) -> 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]: + headers: dict[str, str] = {} + 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.IncompleteReadError as ex: + # May happen when shutting down. parse_content_length will then return None, + # which will cause the read loop to stop. + if ex.partial: + # Propagate server's output to the UI. + raise + 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, 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 - try: - headers = http.client.parse_headers(self._reader) - 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)) - 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 + async def read(self) -> JSONRPCMessage: + 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: 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() + 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 - def write_bytes(self, payload: bytes) -> None: + async def write_bytes(self, payload: bytes) -> None: self._writer.write(payload) - 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 ------------------------------------------------------------------------------------------------- @final -class TransportWrapper: +class TransportWrapper(TaskContainer): """ Double dispatch-like class that takes a (subclass of) Transport, and provides to a (subclass of) TransportCallbacks appropriately decoded messages. The TransportWrapper is also responsible for keeping the spawned child @@ -348,132 +364,106 @@ def __init__( self, callback_object: TransportCallbacks, transport: Transport, - process: subprocess.Popen[bytes] | None, + process: asyncio.subprocess.Process | None, + process_args: list[str] | None, error_reader: ErrorReader | None, ) -> None: - self._closed = False + TaskContainer.__init__(self) 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 | bytes | None] = Queue(0) - self._reader_thread.start() - self._writer_thread.start() + 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 send(self, payload: JSONRPCMessage) -> None: - self._send_queue.put_nowait(payload) - - def send_bytes(self, payload: bytes) -> None: - self._send_queue.put_nowait(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) - if self._error_reader: - self._error_reader.on_transport_close() - self._error_reader = None - if self._transport: - self._transport.close() - self._transport = None - - def _read_loop(self) -> None: - exception = 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: + await self._transport.write(payload) + + async def send_bytes(self, payload: bytes) -> None: + if self._transport: + await self._transport.write_bytes(payload) + + async def close(self) -> None: + await self.cancel_all_tasks() + if self._error_reader: + self._error_reader.on_transport_close() + self._error_reader = None + if self._transport: + await self._transport.close() + self._transport = None + + async def _read_loop(self) -> None: + exception: Exception | None = 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: - return - if callback_object := self._callback_object(): - callback_object.on_payload(p) - - sublime.set_timeout_async(partial(invoke, payload)) + if callback_object := self._callback_object(): + # Don't block the read loop on handler execution. Otherwise, a request handler that sends its own + # request to the server and awaits the response would deadlock: the read loop is stuck waiting for + # the handler, but the handler is waiting for a response that can only be read by the read loop. + self.create_task(callback_object.on_payload(payload)) except (AttributeError, BrokenPipeError, StopLoopError): pass except Exception as ex: exception = ex - if exception: - self._end(exception) - else: - self._send_queue.put_nowait(None) - - def _end(self, exception: Exception | None) -> None: - exit_code = 0 + 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: 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, 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 - if isinstance(d, bytes): - self._transport.write_bytes(d) - else: - self._transport.write(d) - except (BrokenPipeError, AttributeError): - pass - except Exception as ex: - exception = ex - self._end(exception) + if callback_object := self._callback_object(): + await callback_object.on_transport_close(exit_code or 0, 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 ------------------------------------------------------------------------------------------------------- + class ErrorReader: """ Relays log messages from a raw stream to a (subclass of) TransportCallbacks. @@ -483,28 +473,28 @@ 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._task = asyncio.get_running_loop().create_task(self._loop()) def on_transport_close(self) -> None: self._reader = None - _join_thread(self._thread) + self._task.cancel() - def _loop(self) -> None: + async def _loop(self) -> None: try: while self._reader: - message = self._reader.readline().decode("utf-8", "replace") - if not message: - continue - callback_object = self._callback_object() - if callback_object: + 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): + 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) @@ -531,21 +521,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: @@ -568,7 +554,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, @@ -576,10 +562,10 @@ 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, stdin=stdin, stdout=stdout, stderr=stderr, @@ -603,12 +589,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) diff --git a/plugin/core/types.py b/plugin/core/types.py index 04e41b8e5..4bc71b878 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -10,6 +10,8 @@ from ...protocol import TextDocumentSyncKind from ...protocol import TextDocumentSyncOptions from ...protocol import URI +from .aio import run_coroutine +from .aio import TaskContainer from .collections import DottedDict from .constants import LANGUAGE_IDENTIFIERS from .constants import MarkdownLangMap @@ -44,6 +46,7 @@ from wcmatch.glob import globmatch from wcmatch.glob import GLOBSTAR from wcmatch.glob import IGNORECASE +import asyncio import contextlib import fnmatch import os @@ -51,6 +54,7 @@ import re import sublime import time +import weakref if TYPE_CHECKING: from .file_watcher import FileWatcherEventType @@ -164,24 +168,23 @@ def sublime_pattern_to_glob(pattern: str, *, is_directory_pattern: bool, root_pa 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 :param condition: The condition that must evaluate to True in order to run the function - :param async_thread: If true, run the function on the async worker thread, otherwise run the function on the - 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) + run_coroutine(run()) @dataclass @@ -208,12 +211,11 @@ class DebouncerNonThreadSafe: When calling `debounce()` multiple times, if the time span between calls is shorter than the specified `timeout_ms`, the callback function will only be called once, after `timeout_ms` since the last call. - This implementation is not thread safe. You must ensure that `debounce()` is called from the same thread as - was chosen during initialization through the `async_thread` argument. + This implementation is not thread safe. You must ensure that `debounce()` is called from the asyncio thread. """ - def __init__(self, async_thread: bool) -> None: - self._async_thread = async_thread + def __init__(self, task_container: TaskContainer) -> None: + self._task_container = weakref.ref(task_container) self._current_id = -1 self._next_id = 0 @@ -221,23 +223,26 @@ 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 """ + task_container = self._task_container() + if not task_container: + return - 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) + 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 d10e2eedc..09094f68c 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -17,6 +17,10 @@ from ..api import LspPlugin from ..api import OnPreStartContext from ..api import PluginStartError +from .aio import gather_and_flatten_exceptions +from .aio import run_coroutine +from .aio import run_on_asyncio_thread +from .aio import run_on_threadpool from .configurations import RETRY_COUNT_TIMEDELTA from .configurations import RETRY_MAX_COUNT from .configurations import WindowConfigChangeListener @@ -24,13 +28,13 @@ 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 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 Notification from .protocol import Point @@ -54,7 +58,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 @@ -64,6 +67,7 @@ from typing_extensions import override from weakref import ref from weakref import WeakSet +import asyncio import functools import json import sublime @@ -92,12 +96,10 @@ 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 | None = None self._sessions: set[Session] = set() self._workspace = workspace - self._pending_listeners: deque[AbstractViewListener] = deque() self._listeners: WeakSet[AbstractViewListener] = WeakSet() - self._new_listener: AbstractViewListener | None = None - self._new_session: Session | None = None self._panel_code_phantoms: sublime.PhantomSet | None = None self._server_log: list[tuple[str, str]] = [] self.panel_manager: PanelManager | None = PanelManager(self._window) @@ -112,6 +114,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 @@ -157,9 +160,18 @@ 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() - self._pending_listeners.appendleft(listener) - if self._new_listener is None: - self._dequeue_listener_async() + workspace_folders = self._workspace.get_workspace_folders() + self._listeners.add(listener) + for config in self._config_manager.match_view(listener.view, workspace_folders): + if plugin := get_plugin(config.name): + if issubclass(plugin, LspPlugin): + context = IsApplicableContext(config, listener.view, workspace_folders) + if plugin.is_applicable_async(context): + run_coroutine(self.start(config, listener)) + elif plugin.is_applicable(listener.view, config): + run_coroutine(self.start(config, listener)) + else: + run_coroutine(self.start(config, listener)) def unregister_listener_async(self, listener: AbstractViewListener) -> None: self._listeners.discard(listener) @@ -173,13 +185,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) @@ -188,62 +197,11 @@ def recheck_is_applicable_async(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: - self.start_async(config, view) - if self._new_session: - self._sessions.add(self._new_session) - listener.on_session_initialized_async(self._new_session) - self._new_session = 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] - for session in self._sessions: - if session.can_handle(listener.view, scheme, capability=None, inside_workspace=inside_workspace): - # debug("registering session", session.config.name, "to listener", listener) - try: - listener.on_session_initialized_async(session) - except Exception as ex: - message = f"failed to register session {session.config.name} to listener {listener}" - exception_log(message, ex) + 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) @@ -262,103 +220,102 @@ 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: - 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 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): - return config - else: - return config - return 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) + 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. + session.config.set_view_status(listener.view, "") + # 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, {}) + config.set_view_status_handler(self) - def start_async(self, config: ClientConfig, initiating_view: sublime.View) -> None: - config = ClientConfig.from_config(config, {}) - config.set_view_status_handler(self) - file_path = initiating_view.file_name() or '' - if not self._can_start_config(config.name, file_path): - return - try: - workspace_folders = sorted_workspace_folders(self._workspace.folders, file_path) - plugin_class = get_plugin(config.name) - variables = extract_variables(self._window) - cwd = workspace_folders[0].path if workspace_folders else None - context = OnPreStartContext(config, variables, initiating_view, cwd, workspace_folders) - if plugin_class: - if issubclass(plugin_class, LspPlugin): - config.set_view_status(initiating_view, "installing...") - plugin_class.on_pre_start_async(context) - cwd = context.working_directory - else: - 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: - raise PluginStartError(cannot_start_reason) - if new_cwd := plugin_class.on_pre_start(self._window, initiating_view, workspace_folders, config): - cwd = new_cwd - config.set_view_status(initiating_view, "starting...") - session = Session(self, self._create_logger(config.name), workspace_folders, config, plugin_class) - transport = config.create_transport_config().start(config.command, config.env, cwd, variables, session) - if plugin_class and issubclass(plugin_class, AbstractPlugin): - plugin_class.on_post_start(self._window, initiating_view, workspace_folders, config) - config.set_view_status(initiating_view, "initialize") - session.initialize_async( - variables=variables, - transport=transport, - working_directory=cwd, - init_callback=functools.partial(self._on_post_session_initialize, initiating_view) - ) - self._new_session = session - except PluginStartError as ex: - config.erase_view_status(initiating_view) - message = f"cannot start {config.name}: {ex!s}" - 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) - 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) - config.erase_view_status(initiating_view) - sublime.message_dialog(message) - # Continue with handling pending listeners - self._new_session = None - sublime.set_timeout_async(self._dequeue_listener_async) - - def _on_post_session_initialize( - self, initiating_view: sublime.View, session: Session, is_error: bool = False - ) -> None: - if is_error: - session.config.erase_view_status(initiating_view) - self._new_listener = None - self._new_session = None - else: - sublime.set_timeout_async(self._dequeue_listener_async) + try: + workspace_folders = sorted_workspace_folders(self._workspace.folders, file_path) + plugin_class = get_plugin(config.name) + variables = extract_variables(self._window) + cwd = workspace_folders[0].path if workspace_folders else None + context = OnPreStartContext(config, variables, listener.view, cwd, workspace_folders) + if plugin_class: + if issubclass(plugin_class, LspPlugin): + config.set_view_status(listener.view, "installing...") + await plugin_class.on_pre_start(context) + cwd = context.working_directory + else: + if plugin_class.needs_update_or_installation(): + config.set_view_status(listener.view, "installing...") + # Historically these methods tended to run relatively slow. + # We don't want to use Sublime's worker thread for this any longer. + # Utilize the default thread pool instead. + # https://docs.python.org/3/library/asyncio-dev.html#running-blocking-code + await run_on_threadpool(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, listener.view, workspace_folders, config) + if cannot_start_reason: + raise PluginStartError(cannot_start_reason) + if new_cwd := plugin_class.on_pre_start(self._window, listener.view, workspace_folders, config): + cwd = new_cwd + config.set_view_status(listener.view, "starting...") + session = Session(self, self._create_logger(config.name), workspace_folders, config, plugin_class) + transport = await config.create_transport_config().start( + 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: + 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) + 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 ' + 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(listener.view) + sublime.message_dialog(message) + return None + + try: + config.set_view_status(listener.view, "initializing...") + initialize_result = await session.initialize( + variables=variables, transport=transport, working_directory=cwd + ) + if isinstance(initialize_result, Error): + raise initialize_result + self._sessions.add(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 = ( + 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) + config.erase_view_status(listener.view) + else: + return session + return None def _create_logger(self, config_name: str) -> Logger: logger_map = { @@ -380,26 +337,32 @@ def _create_logger(self, config_name: str) -> Logger: router_logger.append(logger(self, config_name)) return router_logger - def handle_message_request( + @override + 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 await 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]) -> 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_async(self, config_names: list[str] | None = None) -> 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: - session.end_async() + debug(f"stopping {session.config.name}") + coros.append(session.end()) self._sessions.discard(session) + return await gather_and_flatten_exceptions(*coros) + @override def get_project_path(self, file_path: str) -> str | None: candidate: str | None = None for folder in self._workspace.folders: @@ -408,6 +371,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": @@ -432,18 +396,26 @@ 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: + @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) - 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") @@ -453,16 +425,15 @@ def on_post_exit_async(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[Exception]: + """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 + @override def handle_log_message(self, config_name: str, params: LogMessageParams) -> None: if not userprefs().log_debug: return @@ -473,6 +444,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) @@ -496,6 +468,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'] @@ -503,6 +476,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 @@ -589,7 +563,8 @@ def notify_did_delete_files(self, deleted_files: list[FileDelete]) -> None: 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)) + # TODO: handle exception list? + run_coroutine(self.restart_sessions(config_names)) def on_server_settings_changed(self, configs: list[ClientConfig]) -> None: for config in configs: @@ -629,14 +604,11 @@ def enable(self) -> None: for window in sublime.windows(): self.lookup(window) - def disable(self) -> None: + async def disable(self) -> list[Exception]: self._enabled = False - for wm in self._windows.values(): - try: - wm.destroy() - except Exception as ex: - exception_log("failed to destroy window", ex) + 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(): @@ -656,7 +628,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(wm.destroy()) # --- Implements LspSettingsChangeListener ------------------------------------------------------------------------- @@ -668,9 +640,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) + run_on_asyncio_thread(session.on_userprefs_changed_async) for listener in wm.listeners(): - sublime.set_timeout_async(listener.on_userprefs_changed_async) + run_on_asyncio_thread(listener.on_userprefs_changed_async) class RequestTimeTracker: diff --git a/plugin/diagnostics.py b/plugin/diagnostics.py index 90e4626e1..08369dc18 100644 --- a/plugin/diagnostics.py +++ b/plugin/diagnostics.py @@ -160,7 +160,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/document_link.py b/plugin/document_link.py index 28bd92de6..519de8c2f 100644 --- a/plugin/document_link.py +++ b/plugin/document_link.py @@ -1,7 +1,10 @@ from __future__ import annotations +from .core.aio import run_coroutine +from .core.logging import exception_log from .core.open import open_file_uri from .core.open import open_in_browser +from .core.protocol import Error from .core.protocol import Request from .core.registry import get_position from .core.registry import LspTextCommand @@ -9,14 +12,12 @@ from .core.url import parse_uri from .core.views import range_to_region from .core.views import text_document_identifier -from functools import partial from typing import TYPE_CHECKING -import sublime if TYPE_CHECKING: - from ..protocol import DocumentLink from ..protocol import URI from .core.sessions import Session + import sublime class LspOpenLinkCommand(LspTextCommand): @@ -35,38 +36,37 @@ def is_enabled(self, event: dict | None = None, point: int | None = None) -> boo return True def run(self, edit: sublime.Edit, event: dict | None = None, point: int | None = None) -> None: - sublime.set_timeout_async(lambda: self._run_async(event, point)) + run_coroutine(self._run(event, point)) - def _run_async(self, event: dict | None, point: int | None) -> None: + async def _run(self, event: dict | None, point: int | None) -> None: if (position := get_position(self.view, event, point)) is not None: if session := self.best_session(self.capability, position): - session.send_request_async( - Request.documentLink({'textDocument': text_document_identifier(self.view)}, self.view), - partial(self._on_response_async, session, position) + response = await session.request( + Request.documentLink({'textDocument': text_document_identifier(self.view)}, self.view) ) + if isinstance(response, Error): + return + for link in response or []: + if range_to_region(link['range'], self.view).contains(position): + if (uri := link.get('target')) is not None: + await self._open_uri(session, uri) + elif session.has_capability('documentLinkProvider.resolveProvider'): + link = await session.request(Request.resolveDocumentLink(link, self.view)) + if isinstance(link, Error): + exception_log("error resolving link", link) + continue + if uri := link.get('target'): + await self._open_uri(session, uri) + return + if window := self.view.window(): + window.status_message('No link available') - def _on_response_async(self, session: Session, point: int, response: list[DocumentLink] | None) -> None: - for link in response or []: - if range_to_region(link['range'], self.view).contains(point): - if (uri := link.get('target')) is not None: - self._open_uri_async(session, uri) - elif session.has_capability('documentLinkProvider.resolveProvider'): - request = Request.resolveDocumentLink(link, self.view) - session.send_request_async(request, partial(self._on_resolved_async, session)) - return - if window := self.view.window(): - window.status_message('No link available') - - def _on_resolved_async(self, session: Session, response: DocumentLink) -> None: - if uri := response.get('target'): - self._open_uri_async(session, uri) - - def _open_uri_async(self, session: Session, uri: URI) -> None: + async def _open_uri(self, session: Session, uri: URI) -> None: scheme = parse_uri(uri)[0] if scheme == 'file': if window := self.view.window(): - open_file_uri(window, uri) + await open_file_uri(window, uri) elif scheme.lower() in {'http', 'https'} or (not scheme and uri.startswith('www.')): open_in_browser(uri) else: - session.open_uri_async(uri) + await session.open_uri(uri) diff --git a/plugin/documents.py b/plugin/documents.py index 47e323dd2..c3a526a13 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -11,20 +11,23 @@ from ..protocol import DocumentUri from ..protocol import FoldingRange from ..protocol import FoldingRangeParams -from ..protocol import SignatureHelp from ..protocol import SignatureHelpContext from ..protocol import SignatureHelpParams from ..protocol import SignatureHelpTriggerKind from .code_actions import filter_quickfix_actions from .code_lens import LspToggleCodeLensesCommand from .completion import QueryCompletionsTask +from .core.aio import gather_and_flatten_exceptions +from .core.aio import get_clipboard +from .core.aio import run_coroutine +from .core.aio import run_on_asyncio_thread +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 from .core.constants import DOCUMENT_HIGHLIGHT_KIND_SCOPES from .core.constants import HOVER_ENABLED_KEY from .core.constants import LIGHTBULB_SCOPE -from .core.constants import MarkdownLangMap from .core.constants import RegionKey from .core.constants import RequestFlags from .core.constants import SIGNATURE_HELP_ACTIVE_PARAMETER_SCOPE @@ -32,10 +35,12 @@ from .core.constants import SIGNATURE_HELP_INACTIVE_PARAMETER_SCOPE from .core.constants import ST_VERSION from .core.logging import debug +from .core.logging import exception_log from .core.open import open_file_uri from .core.open import open_in_browser from .core.panels import PanelName from .core.promise import Promise +from .core.protocol import Error from .core.protocol import Request from .core.registry import best_session from .core.registry import get_position @@ -66,50 +71,64 @@ from .core.views import text_document_position_params from .core.views import update_lsp_popup from .folding_range import folding_range_to_range +from .formatting import format_selection from .session_view import SessionView from functools import partial from functools import wraps 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 deprecated 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 import sublime_plugin import webbrowser 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: @@ -159,20 +178,26 @@ def on_text_changed(self, changes: list[sublime.TextChange]) -> None: change_count = view.change_count() frozen_listeners = WeakSet(self.view_listeners) - def notify(action: ChangeEventAction) -> None: + def notify(action: ChangeEventAction, changes: list[sublime.TextChange]) -> 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)) + run_on_asyncio_thread(notify, self._last_edit_action, changes) self._reset_last_edit_action() def on_reload_async(self) -> None: - for listener in list(self.view_listeners): - listener.reload_async() + + async def run() -> None: + await asyncio.gather(*(listener.reload() for listener in list(self.view_listeners))) + + run_coroutine(run()) def on_revert_async(self) -> None: - for listener in list(self.view_listeners): - listener.revert_async() + + async def run() -> None: + await asyncio.gather(*(listener.revert() for listener in list(self.view_listeners))) + + run_coroutine(run()) def set_last_edit_action(self, action: ChangeEventAction) -> None: self._last_edit_action = action @@ -187,7 +212,7 @@ def __repr__(self) -> str: return f"TextChangeListener({self.buffer.buffer_id})" -class DocumentSyncListener(sublime_plugin.ViewEventListener, AbstractViewListener): +class DocumentSyncListener(sublime_aio.ViewEventListener, AbstractViewListener, TaskContainer): ACTIVE_DIAGNOSTIC = "lsp_active_diagnostic" debounce_time = FEATURES_TIMEOUT @@ -209,7 +234,6 @@ def __init__(self, view: sublime.View) -> None: self._auto_complete_triggered_manually = False self._change_count_on_last_save = -1 self._registration = SettingsRegistration(settings, on_change=self._on_settings_object_changed) - self._completions_task: QueryCompletionsTask | None = None self._is_documenation_popup_open = False self._stored_selection: list[sublime.Region] = [] self._should_format_on_paste = False @@ -248,7 +272,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(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 @@ -256,8 +280,8 @@ def _reset(self) -> None: self._setup() for session in self.sessions_async(): 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 + run_coroutine(self._activated_impl()) def before_destroy(self) -> None: self._cleanup() @@ -305,19 +329,20 @@ def on_session_initialized_async(self, session: Session) -> None: 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) + if request := sb.semantic_tokens.pending_response: + self.create_task(request.cancel()) 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 @@ -379,12 +404,12 @@ def session_views_async(self) -> list[SessionView]: return list(self._session_views.values()) @requires_session - def on_text_changed_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) + sv.on_text_changed(change_count, changes, action) self._on_view_updated_async() def get_uri(self) -> DocumentUri: @@ -411,50 +436,60 @@ def get_request_flags(self, session: Session) -> RequestFlags: # --- Callbacks from Sublime Text ---------------------------------------------------------------------------------- - def on_load_async(self) -> None: + 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_async() + self._register() return 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_activated_async() + result = await session.request(Request.foldingRange(params, self.view)) + if not isinstance(result, Error): + self._on_initial_folding_ranges(initially_folded_kinds, result) + await self._activated_impl() - 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() - def on_activated_async(self) -> None: + async def on_activated(self) -> None: + await self._activated_impl() + + async def _activated_impl(self) -> None: if self.view.is_loading() or not is_regular_view(self.view): return if not self._registered: - self._register_async() + self._register() session_views = self.session_views_async() if not session_views: return for sb in self.session_buffers_async(): if sb.pending_refreshes & RequestFlags.CODE_LENS: - sb.do_code_lenses_async(self.view) + sb.create_task(sb.do_code_lenses(self.view)) if sb.pending_refreshes & RequestFlags.DIAGNOSTIC: - 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: - sb.do_semantic_tokens_async(self.view) - if sb.pending_refreshes & RequestFlags.INLAY_HINT \ - and (session_view := sb.session.session_view_for_view_async(self.view)) \ - and session_view.get_request_flags() & RequestFlags.INLAY_HINT: - sb.do_inlay_hints_async(self.view) + sb.create_task(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 + ): + sb.create_task(sb.do_semantic_tokens(self.view)) + if ( + sb.pending_refreshes & RequestFlags.INLAY_HINT + and (session_view := sb.session.session_view_for_view_async(self.view)) + and session_view.get_request_flags() & RequestFlags.INLAY_HINT + ): + sb.create_task(sb.do_inlay_hints(self.view)) if userprefs().show_code_actions: self._do_code_actions_for_selection_async(self.session_buffers_async('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): @@ -462,11 +497,11 @@ 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() if userprefs().show_code_actions: @@ -474,29 +509,32 @@ def _on_selection_modified_debounced_async(self) -> None: code_lenses_enabled = LspToggleCodeLensesCommand.are_enabled(self.view.window()) for sv in self.session_views_async(): if code_lenses_enabled: - sv.session_buffer.resolve_visible_code_lenses_async(self.view) + sv.session_buffer.create_task(sv.session_buffer.resolve_visible_code_lenses(self.view)) if plugin := sv.session.plugin: plugin.on_selection_modified_async(sv) - def on_post_save_async(self) -> None: + async def on_post_save(self) -> list[BaseException | 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) new_scheme, _ = parse_uri(uri) old_scheme, _ = parse_uri(self._uri) self.set_uri(uri) + exceptions = [] if new_scheme == old_scheme: # 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) + exceptions = await asyncio.gather( + *(sv.on_post_save(self._uri) for sv in self.session_views_async()), return_exceptions=True + ) else: # The URI scheme has changed. This means we need to re-determine whether any language servers should # be attached to the view. sublime.set_timeout(self._reset) self._change_count_on_last_save = self.view.change_count() self._toggle_diagnostics_panel_if_needed_async() + return exceptions def _toggle_diagnostics_panel_if_needed_async(self) -> None: severity_threshold = userprefs().show_diagnostics_panel_on_save @@ -518,10 +556,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)) + manager.unregister_listener_async(self) self.before_destroy() def on_query_context(self, key: str, operator: int, operand: Any, match_all: bool) -> bool | None: @@ -566,7 +604,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)) + run_on_asyncio_thread(partial(self._on_hover_gutter_async, point)) def _on_hover_gutter_async(self, point: int) -> None: if userprefs().diagnostics_gutter_marker: @@ -605,11 +643,11 @@ 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)) + 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) @@ -646,73 +684,52 @@ 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.set_timeout_async(lambda: self.do_signature_help_async(SignatureHelpTriggerKind.ContentChange)) + run_coroutine(self.do_signature_help(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", "delete_to_mark", "left_delete", "right_delete"}: self.view.hide_popup() - @requires_session - def on_query_completions(self, prefix: str, locations: list[int]) -> sublime.CompletionList | None: - 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( - lambda: self._on_query_completions_async(completion_list, locations[0], triggered_manually)) - return completion_list - # --- textDocument/complete ---------------------------------------------------------------------------------------- - def _on_query_completions_async( - self, clist: sublime.CompletionList, location: int, triggered_manually: bool - ) -> None: - if self._completions_task: - 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) + async def on_query_completions(self, prefix: str, locations: list[int]) -> sublime.CompletionList: + # Note: cancellation is initiated by sublime_aio.ViewEventListener (by cancelling the asyncio.Task representing + # on_query_completions). + clist = sublime.CompletionList() + triggered_manually = self._auto_complete_triggered_manually + self._auto_complete_triggered_manually = False # reset state for next completion popup sessions = list(self.sessions_async('completionProvider')) if not sessions or not self.view.is_valid(): - self._completions_task.cancel_async() - return - self.purge_changes_async() - self._completions_task.query_completions_async(sessions) - - def _on_query_completions_resolved_async( - self, - clist: sublime.CompletionList, - completions: list[sublime.CompletionItem], - flags: sublime.AutoCompleteFlags = sublime.AutoCompleteFlags.NONE - ) -> None: - self._completions_task = None - if ST_VERSION >= 4184: # https://github.com/sublimehq/sublime_text/issues/6249#issuecomment-2502804237 - clist.set_completions(completions, flags) - else: - # Resolve on the main thread to prevent any sort of data race for _set_target (see sublime_plugin.py). - sublime.set_timeout(lambda: clist.set_completions(completions, flags)) + return clist + await self.purge_changes() + clist.set_completions( + *await QueryCompletionsTask(self.view, locations[0], triggered_manually).query_completions(sessions) + ) + return clist # --- textDocument/signatureHelp ----------------------------------------------------------------------------------- @overload - def do_signature_help_async( + async def do_signature_help( self, trigger_kind: Literal[SignatureHelpTriggerKind.TriggerCharacter], trigger_char: str ) -> None: ... @overload - def do_signature_help_async( + async def do_signature_help( self, trigger_kind: Literal[SignatureHelpTriggerKind.Invoked, SignatureHelpTriggerKind.ContentChange], trigger_char: None = None ) -> None: ... @override - def do_signature_help_async(self, trigger_kind: SignatureHelpTriggerKind, trigger_char: str | None = None) -> None: + async def do_signature_help(self, trigger_kind: SignatureHelpTriggerKind, trigger_char: str | None = None) -> None: session = self._get_signature_help_session() if not session or not self._stored_selection: return - self.purge_changes_async() + await self.purge_changes() position = self._stored_selection[0].a context_params: SignatureHelpContext = { 'triggerKind': trigger_kind, @@ -729,9 +746,22 @@ def do_signature_help_async(self, trigger_kind: SignatureHelpTriggerKind, trigge "position": position_params["position"], "context": context_params } - language_map = session.markdown_language_id_to_st_syntax_map() - request = Request.signatureHelp(params, self.view) - session.send_request_async(request, lambda resp: self._on_signature_help(resp, position, language_map)) + result = await session.request(Request.signatureHelp(params, self.view)) + if isinstance(result, Error): + exception_log("Error loading signature help", result) + return + new_sighelp = SigHelp.from_lsp( + result, + session.markdown_language_id_to_st_syntax_map(), + self._signature_help_style, + ) + if not new_sighelp: + if self._sighelp and not self.view.match_selector(position, 'meta.function-call.arguments'): + self.view.hide_popup() + return + content = new_sighelp.render(self.view) + # Show on main thread. + sublime.set_timeout(lambda: self._show_sighelp_popup(new_sighelp, content, position)) def _get_signature_help_session(self) -> Session | None: # NOTE: We take the beginning of the region to check the previous char (see last_char variable). This is for @@ -765,21 +795,6 @@ def _get_signature_help_style(self) -> SignatureHelpStyle: 'inactive_parameter_color': inactive_parameter_color } - def _on_signature_help( - self, - response: SignatureHelp | None, - point: int, - language_map: MarkdownLangMap | None - ) -> None: - new_sighelp = SigHelp.from_lsp(response, language_map, self._signature_help_style) - if not new_sighelp: - if self._sighelp and not self.view.match_selector(point, 'meta.function-call.arguments'): - self.view.hide_popup() - return - content = new_sighelp.render(self.view) - # Show on main thread. - sublime.set_timeout(lambda: self._show_sighelp_popup(new_sighelp, content, point)) - def _show_sighelp_popup(self, sighelp: SigHelp, content: str, point: int) -> None: if self._sighelp: update_lsp_popup(self.view, content) @@ -962,35 +977,47 @@ def has_capability_async(self, session: Session, capability_path: str) -> bool: return sv.has_capability_async(capability_path) return False + def purge_changes(self) -> asyncio.Future[list[BaseException | None]]: + return asyncio.gather(*(sv.purge_changes() for sv in self.session_views_async()), return_exceptions=True) + + @deprecated("use DocumentSyncListener.purge_changes instead") 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_async(): - sv.on_pre_save_async() + async def run() -> None: + await self.purge_changes() + + self.create_task_threadsafe(run()) - def revert_async(self) -> None: + def trigger_on_pre_save(self) -> asyncio.Future[list[BaseException | None]]: + return asyncio.gather(*(sv.on_pre_save() for sv in self.session_views_async()), return_exceptions=True) + + async def revert(self) -> list[BaseException | None]: + exceptions = [] if self.view.is_primary(): - for sv in self.session_views_async(): - sv.on_revert_async() + exceptions = await asyncio.gather( + *(sv.on_revert() for sv in self.session_views_async()), return_exceptions=True + ) self._on_view_updated_async() + return exceptions - def reload_async(self) -> None: + async def reload(self) -> list[BaseException | None]: + exceptions = [] if self.view.is_primary(): - for sv in self.session_views_async(): - sv.on_reload_async() + exceptions = await asyncio.gather( + *(sv.on_reload() for sv in self.session_views_async()), return_exceptions=True + ) self._on_view_updated_async() + return exceptions # --- 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()) @@ -1018,22 +1045,25 @@ def _register_async(self) -> None: for listener in listeners: if isinstance(listener, DocumentSyncListener): debug("also registering", listener) - listener.on_load_async() + self.create_task(listener._on_load_impl()) 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() + + async def format_on_paste() -> None: + await self._format_on_paste(await get_clipboard()) + + self.create_task(format_on_paste()) + 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_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) + self.create_task(self.do_signature_help(SignatureHelpTriggerKind.ContentChange)) else: session = self._get_signature_help_session() triggers: list[str] = [] @@ -1044,9 +1074,11 @@ def _on_view_updated_async(self) -> None: if triggers: previous_char = self.view.substr(selection[0].a - 1) if previous_char in triggers: - self.do_signature_help_async(SignatureHelpTriggerKind.TriggerCharacter, previous_char) + self.create_task( + self.do_signature_help(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 @@ -1068,7 +1100,7 @@ def _update_stored_selection_async(self) -> tuple[sublime.Region | None, bool]: self._stored_selection = selection return changed_first_region, True - def _format_on_paste_async(self, clipboard_text: str) -> None: + async def _format_on_paste(self, clipboard_text: str) -> None: sel = self.view.sel() split_clipboard_text = clipboard_text.split('\n') multi_cursor_paste = len(split_clipboard_text) == len(sel) and len(sel) > 1 @@ -1091,26 +1123,24 @@ 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() + await self.purge_changes() + sel.add_all(regions_to_format) - def run_sync() -> None: - sel.add_all(regions_to_format) - self.view.run_command('lsp_format_document_range') + def restore_selection() -> None: sel.clear() sel.add_all(original_selection) - sublime.set_timeout(run_sync) + try: + await format_selection(self) + sublime.status_message("Paste was formatted") + finally: + sublime.set_timeout(restore_selection) - 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() - - sublime.set_timeout_async(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/edit.py b/plugin/edit.py index 2a81959c0..17901653d 100644 --- a/plugin/edit.py +++ b/plugin/edit.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .core.aio import run_coroutine from .core.constants import ChangeEventAction from .core.edit import is_snippet_text_edit from .core.edit import parse_lsp_position @@ -94,10 +95,14 @@ class LspApplyWorkspaceEditCommand(LspWindowCommand): def run( self, session_name: str, edit: WorkspaceEdit, label: str | None = None, is_refactoring: bool = False + ) -> None: + run_coroutine(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/execute_command.py b/plugin/execute_command.py index 76451544b..bd26c3ebf 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 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(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: + result: LSPAny | Error = await session.run_command(params, progress=True, view=self.view) + if isinstance(result, Error): + self.handle_error_async(result, command_name) + else: + self.handle_success_async(result, command_name) def handle_success_async(self, result: Any, command_name: str) -> None: """ diff --git a/plugin/folding_range.py b/plugin/folding_range.py index 73fb315e7..5992fa216 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) ) @@ -156,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) ) @@ -181,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/formatting.py b/plugin/formatting.py index bb4a062af..4e7ab3ce8 100644 --- a/plugin/formatting.py +++ b/plugin/formatting.py @@ -3,9 +3,10 @@ from ..protocol import TextDocumentSaveReason from ..protocol import TextEdit from .code_actions import CodeActionsOnFormatTask +from .core.aio import run_coroutine from .core.collections import DottedDict from .core.edit import apply_text_edits -from .core.promise import Promise +from .core.logging import exception_log from .core.protocol import Error from .core.registry import LspTextCommand from .core.registry import windows @@ -21,8 +22,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 @@ -30,9 +29,10 @@ import sublime if TYPE_CHECKING: + from .core.sessions import AbstractViewListener from .core.sessions import Session -FormatResponse = Union[List[TextEdit], None, Error] +FormatResponse = Union[List[TextEdit], Error, None] def get_formatter(window: sublime.Window | None, base_scope: str) -> str | None: @@ -44,18 +44,53 @@ 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 + + +async def format_selection(listener: AbstractViewListener | None) -> Error | None: + if not listener: + return None + await listener.purge_changes() + session: Session | None = None + text_edits: list[TextEdit] | Error | None = None + selection: sublime.Region | None = None + if has_single_nonempty_selection(listener.view): + session = listener.session_async('documentRangeFormattingProvider') + selection = first_selection_region(listener.view) + if session and selection is not None: + text_edits = await session.request(text_document_range_formatting(listener.view, selection)) + elif listener.view.has_non_empty_selection_region(): + if session := listener.session_async('documentRangeFormattingProvider.rangesSupport'): + text_edits = await session.request(text_document_ranges_formatting(listener.view)) + if text_edits is not None: + if isinstance(text_edits, list): + await apply_text_edits(listener.view, text_edits, label="Format Selection") + if selection: + _maybe_reset_selection_start_async(listener.view, selection.begin()) + else: + return text_edits + return None + + +def _maybe_reset_selection_start_async(view: sublime.View, offset: int) -> None: + # Issue https://github.com/sublimelsp/LSP/issues/2986 + # Some servers return TextEdits that modify content outside of the range to format, which can cause the text + # selection to be updated in an unexpected way. In that case reset the start point of the selection to the + # initial start point before formatting. Only implemented for single-range formatting. + if view.is_valid() and (region := view.sel()[0]).begin() != offset: + new_region = (offset, region.b) if region.a < region.b else (region.a, offset) + view.run_command('lsp_selection_set', {'regions': [new_region]}) class WillSaveWaitTask(LspTask): @@ -63,30 +98,19 @@ 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 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: - 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 __init__(self, text_command: LspTextCommand) -> None: + super().__init__(text_command) - 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()) + async def run(self) -> None: + await super().run() + for session in self._text_command.sessions('textDocumentSync.willSaveWaitUntil'): + await self._purge_changes() + view = self._text_command.view + text_edits = await session.request(will_save_wait_until(view, reason=TextDocumentSaveReason.Manual)) + if isinstance(text_edits, Error): + sublime.status_message(f"Failed to apply Will Save Task: {text_edits}") + elif text_edits: + await apply_text_edits(self._text_command.view, text_edits, label="Format on Save") class FormatOnSaveTask(LspTask): @@ -99,27 +123,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() - self._purge_changes_async() - syntax = self._task_runner.view.syntax() + async def run(self) -> None: + await super().run() + await self._purge_changes() + 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) + text_edits = await format_document(self._text_command, formatter) + if isinstance(text_edits, Error): + sublime.status_message(f"Failed to apply Format On Save: {text_edits}") + elif text_edits: + await apply_text_edits(self._text_command.view, text_edits, label="Format On Save") class LspFormatDocumentCommand(LspTextCommandWithTasks): capability = 'documentFormattingProvider' + label = 'Format File' + @property @override def tasks(self) -> list[type[LspTask]]: @@ -132,31 +156,38 @@ 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]) -> Error | None: session_names = [session.config.name for session in self.sessions(self.capability)] syntax = self.view.syntax() if not syntax: - return + return None base_scope = syntax.scope if select: self.select_formatter(base_scope, session_names) - return + return None if listener := self.get_listener(): - listener.purge_changes_async() + await listener.purge_changes() 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) - return + if formatter := get_formatter(self.view.window(), base_scope): + if session := self.session_by_name(formatter, self.capability): + text_edits = await session.request(text_document_formatting(self.view)) + if isinstance(text_edits, Error): + return text_edits + return await self._apply_text_edits(text_edits, label=self.label) self.select_formatter(base_scope, session_names) else: - format_document(self).then(self.on_result_async) + text_edits = await format_document(self) + if isinstance(text_edits, Error): + return text_edits + return await self._apply_text_edits(text_edits, label=self.label) + return None - def on_result_async(self, result: FormatResponse) -> None: - if result and not isinstance(result, Error): - 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(): @@ -182,10 +213,18 @@ 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(): + await listener.purge_changes() + result = await session.request(text_document_formatting(self.view)) + if isinstance(result, Error): + exception_log("failed to apply formatting", result) + else: + await self._apply_text_edits(result, label=self.label) + + run_coroutine(do_format()) class LspFormatDocumentRangeCommand(LspTextCommand): @@ -203,35 +242,11 @@ 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: - if listener := self.get_listener(): - listener.purge_changes_async() - if has_single_nonempty_selection(self.view): - session = self.best_session(self.capability) - selection_region = first_selection_region(self.view) - if session and selection_region is not None: - request = text_document_range_formatting(self.view, selection_region) - session.send_request_task(request).then(self._handle_response_async).then( - lambda view: self._maybe_reset_selection_start_async(selection_region.begin()) if view else None - ) - 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) -> Promise[sublime.View | None]: - if isinstance(response, Error): - sublime.status_message(f'Formatting error: {response}') - return Promise.resolve(None) - return apply_text_edits(self.view, response, label="Format Selection") if response else Promise.resolve(None) - - def _maybe_reset_selection_start_async(self, offset: int) -> None: - # Issue https://github.com/sublimelsp/LSP/issues/2986 - # Some servers return TextEdits that modify content outside of the range to format, which can cause the text - # selection to be updated in an unexpected way. In that case reset the start point of the selection to the - # initial start point before formatting. Only implemented for single-range formatting. - if self.view.is_valid() and (region := self.view.sel()[0]).begin() != offset: - new_region = (offset, region.b) if region.a < region.b else (region.a, offset) - self.view.run_command('lsp_selection_set', {'regions': [new_region]}) + run_coroutine(self._run()) + + async def _run(self) -> None: + if (potential_error := await format_selection(self.get_listener())) and isinstance(potential_error, Error): + sublime.status_message(f'Formatting error: {potential_error}') class LspFormatCommand(LspTextCommand): diff --git a/plugin/goto.py b/plugin/goto.py index f39460581..9165e0d6e 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 from .core.constants import DIAGNOSTIC_KINDS from .core.input_handlers import PreselectedListInputHandler from .core.paths import simple_project_path @@ -25,7 +26,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 @@ -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) + 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) + 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)) + run_coroutine(session.open_location(location)) def _session(self, value: DiagnosticData) -> Session | None: session_name = value['session_name'] diff --git a/plugin/hierarchy.py b/plugin/hierarchy.py index a58c97046..c1c993397 100644 --- a/plugin/hierarchy.py +++ b/plugin/hierarchy.py @@ -166,7 +166,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/hover.py b/plugin/hover.py index c9c370dac..e9e57c27c 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 run_coroutine +from .core.aio import run_on_asyncio_thread from .core.constants import HOVER_ENABLED_KEY from .core.constants import MarkdownLangMap from .core.constants import RegionKey @@ -142,7 +144,7 @@ def run_async() -> None: ] Promise.all(code_action_promises).then(partial(self._handle_code_actions, listener, hover_point)) - sublime.set_timeout_async(run_async) + run_on_asyncio_thread(run_async) def request_symbol_hover_async(self, listener: AbstractViewListener, point: int) -> None: hover_promises: list[Promise[ResolvedHover]] = [] @@ -327,11 +329,16 @@ def _on_navigate(self, uri: str) -> None: pass elif scheme == 'file': if window := self.view.window(): - open_file_uri(window, uri).then(lambda view: window.focus_view(view) if view else None) + + async def open_file() -> None: + if view := await open_file_uri(window, uri): + window.focus_view(view) + + run_coroutine(open_file()) 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(session.run_code_action(action, progress=True, view=self.view)) self.view.hide_popup() elif scheme == DOCUMENT_LINK_SCHEME: session_name, version, link = decode_document_link_uri(uri) @@ -344,19 +351,19 @@ def _on_navigate(self, uri: str) -> 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(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(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: + if isinstance(await session.open_uri(uri, r), sublime.View): return @@ -373,7 +380,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)) + run_on_asyncio_thread(self._update_views_async, enable) def _has_hover_provider(self, view: sublime.View) -> bool: listener = windows.listener_for_view(view) diff --git a/plugin/inlay_hint.py b/plugin/inlay_hint.py index 77fc43be2..172a22bdf 100644 --- a/plugin/inlay_hint.py +++ b/plugin/inlay_hint.py @@ -1,16 +1,20 @@ from __future__ import annotations +from .core.aio import run_coroutine from .core.constants import RequestFlags from .core.constants import ST_VERSION from .core.css import css from .core.edit import apply_text_edits +from .core.protocol import Error from .core.protocol import Request from .core.registry import LspTextCommand from .core.registry import LspWindowCommand from .core.settings import userprefs from .core.views import position_to_offset +from typing import Awaitable from typing import cast from typing import TYPE_CHECKING +import asyncio import html import sublime import uuid @@ -39,18 +43,23 @@ def _get_default_value() -> bool: return False def run(self, enable: bool | None = None) -> None: + run_coroutine(self._run(enable)) + + async def _run(self, enable: bool | None) -> None: window_settings = self.window.settings() if not isinstance(enable, bool): enable = not bool(window_settings.get('lsp_show_inlay_hints')) window_settings.set('lsp_show_inlay_hints', enable) - status = 'on' if enable else 'off' - sublime.status_message(f'Inlay Hints are {status}') + coros: list[Awaitable[None]] = [] for session in self.sessions(): 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: - sv.session_buffer.do_inlay_hints_async(sv.view) + coros.append(sv.session_buffer.do_inlay_hints(sv.view)) + await asyncio.gather(*coros) + status = 'on' if enable else 'off' + sublime.status_message(f'Inlay Hints are {status}') def is_checked(self) -> bool: return bool(self.window.settings().get('lsp_show_inlay_hints')) @@ -61,46 +70,30 @@ 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(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_async( - 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) + result = await session.request(Request.resolveInlayHint(inlay_hint, self.view)) + if not isinstance(result, Error): + inlay_hint = result + + 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/locationpicker.py b/plugin/locationpicker.py index ae1391eac..3c2fbb558 100644 --- a/plugin/locationpicker.py +++ b/plugin/locationpicker.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .core.aio import run_coroutine from .core.constants import ST_PACKAGES_PATH from .core.constants import SublimeKind from .core.logging import debug @@ -8,7 +9,6 @@ from .core.views import to_encoded_filename from typing import TYPE_CHECKING from urllib.request import url2pathname -import functools import sublime import weakref @@ -20,7 +20,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 +32,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 +125,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)) + 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 diff --git a/plugin/lsp_task.py b/plugin/lsp_task.py index 13f656922..199f75815 100644 --- a/plugin/lsp_task.py +++ b/plugin/lsp_task.py @@ -1,14 +1,14 @@ from __future__ import annotations +from .core.aio import run_coroutine +from .core.logging import exception_log from .core.registry import LspTextCommand from .core.settings import userprefs 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_extensions import override +import asyncio import sublime @@ -16,7 +16,7 @@ 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 @@ -24,84 +24,19 @@ 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() - sublime.set_timeout_async(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) - sublime.set_timeout_async(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() - - def _purge_changes_async(self) -> None: - if listener := self._task_runner.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 - - 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. - sublime.set_timeout_async(self._run_next_task_async) - else: - self._on_tasks_completed() - - def _run_next_task_async(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() + self._text_command.view.erase_status(self._status_key) + + async def _purge_changes(self) -> None: + if listener := self._text_command.get_listener(): + await listener.purge_changes() class LspTextCommandWithTasks(LspTextCommand, ABC): @@ -113,22 +48,45 @@ 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: + async 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(self._run(**kwargs)) + + async def _run(self, **kwargs: dict[str, Any]) -> None: if self._tasks_runner: - 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() + # Request to cancel the task. + if self._tasks_runner.cancel(): + try: + # Wait for the task to actually finish. + await self._tasks_runner + except asyncio.CancelledError: + # It's going to throw this exception so catch it. + pass + self._tasks_runner = None + await self.on_before_tasks() + self._tasks_runner = asyncio.create_task(self._run_tasks()) + try: + await self._tasks_runner + except TimeoutError as ex: + sublime.status_message(str(ex)) + except Exception as ex: + sublime.status_message("Error running save tasks. See the Console for more information.") + exception_log("Error running save tasks", ex) + finally: + await self.on_tasks_completed(**kwargs) + + async def _run_tasks(self) -> None: + for task in self.tasks: + if task.is_applicable(self.view): + try: + await asyncio.wait_for(task(self).run(), timeout=userprefs().on_save_task_timeout_ms / 1000) + except asyncio.TimeoutError as ex: + raise TimeoutError(f'Timeout processing {task.__name__}') from ex diff --git a/plugin/rename.py b/plugin/rename.py index 516b64b4a..323cd364c 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .core.aio import run_coroutine from .core.edit import show_summary_message from .core.protocol import Request from .core.registry import get_position @@ -102,7 +103,11 @@ def run( point: int | None = None ) -> None: if listener := self.get_listener(): - listener.purge_changes_async() + + async def purge() -> None: + await listener.purge_changes() + + run_coroutine(purge()) location = get_position(self.view, event, point) session = self._get_prepare_rename_session(point, session_name) if new_name or placeholder or not session: @@ -148,8 +153,8 @@ def on_prompt_for_workspace_edits_concluded( self, weak_session: weakref.ref[Session], response: WorkspaceEdit, accepted: bool ) -> None: if accepted and (session := weak_session()): - session.apply_workspace_edit_async(response, is_refactoring=True) \ - .then(lambda tup: show_summary_message(session.window, *tup)) + future = run_coroutine(session.apply_workspace_edit(response, is_refactoring=True)) + future.add_done_callback(lambda f: show_summary_message(session.window, *f.result())) def _on_prepare_result(self, pos: int, session_name: str | None, response: PrepareRenameResult | None) -> None: if response is None: diff --git a/plugin/rename_file.py b/plugin/rename_file.py index 2ad651a52..275e03511 100644 --- a/plugin/rename_file.py +++ b/plugin/rename_file.py @@ -1,5 +1,7 @@ from __future__ import annotations +from .core.aio import run_coroutine +from .core.aio import run_on_asyncio_thread from .core.edit import show_summary_message from .core.logging import debug from .core.open import open_file_uri @@ -105,9 +107,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)) + + run_on_asyncio_thread(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(run()) def on_rename_path(self, success: bool, file_rename: FileRename) -> None: if success and (mgr := self.manager()): @@ -154,7 +161,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]]] = [] @@ -172,14 +179,14 @@ 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 + 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])) + self.focus_view(last_active_view) + return True def restore_view(self, selection: list[sublime.Region], group: tuple[int, int], view: sublime.View | None) -> None: if not view: diff --git a/plugin/save_command.py b/plugin/save_command.py index fb2731ab2..23d6eb61b 100644 --- a/plugin/save_command.py +++ b/plugin/save_command.py @@ -29,17 +29,17 @@ def tasks(self) -> list[type[LspTask]]: ] @override - def on_before_tasks(self) -> None: - sublime.set_timeout_async(self._trigger_on_pre_save_async) + async def on_before_tasks(self) -> None: + await self._trigger_on_pre_save() @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)) - def _trigger_on_pre_save_async(self) -> None: + async def _trigger_on_pre_save(self) -> None: if listener := self.get_listener(): - listener.trigger_on_pre_save_async() + await listener.trigger_on_pre_save() class LspSaveAllCommand(sublime_plugin.WindowCommand): diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index ebf759c43..3a5e450b4 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -5,7 +5,6 @@ from ..protocol import CodeActionKind from ..protocol import CodeActionParams from ..protocol import CodeActionTriggerKind -from ..protocol import CodeLens from ..protocol import ColorInformation from ..protocol import Command from ..protocol import Diagnostic @@ -19,20 +18,22 @@ from ..protocol import DocumentOnTypeFormattingParams from ..protocol import DocumentUri from ..protocol import FullDocumentDiagnosticReport -from ..protocol import InlayHint from ..protocol import InlayHintParams from ..protocol import LSPErrorCodes from ..protocol import RelatedFullDocumentDiagnosticReport from ..protocol import SemanticTokens from ..protocol import SemanticTokensDelta +from ..protocol import SemanticTokensDeltaParams +from ..protocol import SemanticTokensParams +from ..protocol import SemanticTokensRangeParams from ..protocol import TextDocumentSaveReason from ..protocol import TextDocumentSyncKind -from ..protocol import TextEdit from ..protocol import UnchangedDocumentDiagnosticReport from .api import AbstractPlugin 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 @@ -44,17 +45,16 @@ from .core.constants import SEMANTIC_TOKENS_MAP from .core.constants import SUPPORTED_DIAGNOSTIC_TAGS from .core.edit import apply_text_edits -from .core.promise import Promise +from .core.logging import debug from .core.protocol import Error from .core.protocol import Request from .core.protocol import ResolvedCodeLens -from .core.protocol import ResponseError from .core.sessions import is_diagnostic_server_cancellation_data +from .core.sessions import RequestController from .core.sessions import Session from .core.sessions import SessionViewProtocol from .core.settings import userprefs from .core.types import Capabilities -from .core.types import debounced from .core.types import DebouncerNonThreadSafe from .core.types import FEATURES_TIMEOUT from .core.types import SemanticToken @@ -80,19 +80,24 @@ from .diagnostics import DOCUMENT_DIAGNOSTICS_RETRIGGER_DELAY from .inlay_hint import inlay_hint_to_phantom 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 TYPE_CHECKING from typing_extensions import Concatenate from typing_extensions import deprecated from typing_extensions import ParamSpec from typing_extensions import TypeGuard from weakref import WeakSet +import asyncio import itertools import sublime import time +if TYPE_CHECKING: + from .core.promise import Promise + P = ParamSpec('P') # If the total number of characters in the file exceeds this limit, try to send a semantic tokens request only for the @@ -128,7 +133,7 @@ def update(self, version: int, changes: list[sublime.TextChange]) -> None: @dataclass class PendingDocumentDiagnosticRequest: version: int - request_id: int + request: RequestController class SemanticTokensData: @@ -142,10 +147,15 @@ def __init__(self) -> None: self.active_region_keys: set[int] = set() self.tokens: list[SemanticToken] = [] self.view_change_count = 0 - self.pending_response: int | None = None + self.pending_response: RequestController | None = None + + async def cancel(self) -> None: + if self.pending_response: + self.pending_response, pending_response = None, self.pending_response + await pending_response.cancel() -class SessionBuffer: +class SessionBuffer(TaskContainer): """ Holds state per session per buffer. @@ -155,6 +165,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". @@ -175,7 +186,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(async_thread=True) + self._diagnostics_debouncer_async = DebouncerNonThreadSafe(self) self._color_phantoms = sublime.PhantomSet(view, "lsp_color") self._document_links: list[DocumentLink] = [] self.semantic_tokens = SemanticTokensData() @@ -193,7 +204,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: @@ -212,36 +227,40 @@ def last_synced_version(self) -> int: return self._last_synced_version def on_session_view_initialized(self, view: sublime.View) -> None: - self._check_did_open(view) + self.create_task(self._check_did_open(view)) - def _check_did_open(self, view: sublime.View) -> None: + async def _check_did_open(self, view: sublime.View) -> None: if not self.opened and self.should_notify_did_open(): language_id = self.get_language_id() if not language_id: # we're closing return - self.session.send_notification(did_open(view, language_id)) + try: + await self.session.notify(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 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.create_task(self.do_document_diagnostic(view, version)) if request_flags & RequestFlags.SEMANTIC_TOKENS: - self.do_semantic_tokens_async(view, view.size() > HUGE_FILE_SIZE) + self.create_task(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.create_task(self.do_inlay_hints(view)) + self.create_task(self.do_code_lenses(view)) if userprefs().link_highlight_style == 'underline': self._do_document_link_async(view, version) - self.session.notify_plugin_on_session_buffer_change(self) + await self.session.notify_plugin_on_session_buffer_change(self) - def _check_did_close(self, view: sublime.View) -> None: + async 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.session.send_notification(did_close(uri=self._last_known_uri)) self.opened = False + await self.purge_changes(view, suppress_requests=True) + await self.session.notify(did_close(uri=self._last_known_uri)) def get_uri(self) -> DocumentUri | None: for sv in self.session_views: @@ -271,13 +290,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 @@ -292,8 +312,9 @@ def _on_before_destroy(self, view: sublime.View) -> None: # in unregistering ourselves from the session. if not self.session.exiting: # Only send textDocument/didClose when we are the only view left (i.e. there are no other clones). - self._check_did_close(view) + await self._check_did_close(view) self.session.unregister_session_buffer_async(self) + return await self.cancel_all_tasks() def register_capability_async( self, @@ -311,13 +332,13 @@ def register_capability_async( view = sv.view if view is not None: if capability_path.startswith("textDocumentSync."): - self._check_did_open(view) + self.create_task(self._check_did_open(view)) elif capability_path.startswith("diagnosticProvider"): if not suppress_requests: - self.do_document_diagnostic_async(view, view.change_count()) + self.create_task(self.do_document_diagnostic(view, view.change_count())) elif capability_path.startswith("codeLensProvider"): if not suppress_requests: - self.do_code_lenses_async(view) + self.create_task(self.do_code_lenses(view)) elif capability_path == "executeCommandProvider": self._dynamically_registered_commands[registration_id] = options['commands'] self._update_supported_commands() @@ -368,7 +389,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: @@ -387,37 +408,52 @@ def on_text_changed_async( elif self._pending_changes.version < change_count: self._pending_changes.update(change_count, changes) purge = True - if purge: - self._cancel_pending_requests_async() - if ( - userprefs().format_on_type and last_change.len_utf16 == 0 - and (params := self._get_on_type_formatting_params_async(view, action, last_change.str)) - ): - 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_async(view), FEATURES_TIMEOUT, - lambda: view.is_valid() and change_count == view.change_count(), async_thread=True) + if not purge: + return + self.create_task(self._cancel_pending_requests()) + if ( + userprefs().format_on_type + and last_change.len_utf16 == 0 + and (params := self._get_on_type_formatting_params_async(view, action, last_change.str)) + ): + + async def purge_changes_and_do_on_type_formatting() -> None: + await self.purge_changes(view) + if ( + (result := await self.session.request(Request.onTypeFormatting(params, view))) + and not isinstance(result, Error) + and view.is_valid() + and change_count == view.change_count() + ): + await apply_text_edits(view, result) + + self.create_task(purge_changes_and_do_on_type_formatting()) + else: + + async def maybe_purge_later() -> None: + await asyncio.sleep(FEATURES_TIMEOUT / 1000) + if view.is_valid() and change_count == view.change_count(): + await self.purge_changes(view) + + self.create_task(maybe_purge_later()) - 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) + async def _cancel_pending_requests(self) -> None: + for identifier, pending in self._document_diagnostic_pending_requests.items(): + if pending: + await pending.request.cancel() self._document_diagnostic_pending_requests[identifier] = None if self.semantic_tokens.pending_response: - self.session.cancel_request_async(self.semantic_tokens.pending_response) - self.semantic_tokens.pending_response = None + await self.semantic_tokens.cancel() - def on_revert_async(self, view: sublime.View) -> None: + async def on_revert(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)) + await self.session.notify(did_change(view, version, None)) + await self._on_after_change(view, version) - on_reload_async = on_revert_async + on_reload = on_revert - def purge_changes_async(self, view: sublime.View, suppress_requests: bool = False) -> None: + async 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,18 +465,18 @@ def purge_changes_async(self, view: sublime.View, suppress_requests: bool = Fals else: changes = self._pending_changes.changes version = self._pending_changes.version + # Note: set _pending_changes to None *now*, not *after* the await point. Otherwise pending changes may arrive + # that might be discarded, resulting in a completely messed up state between the lang server and the editor. + self._pending_changes = None try: - notification = did_change(view, version, changes) - self.session.send_notification(notification) + await self.session.notify(did_change(view, version, changes)) self._last_synced_version = version except MissingUriError: return # we're closing - finally: - self._pending_changes = None - self.session.notify_plugin_on_session_buffer_change(self) - sublime.set_timeout_async(lambda: self._on_after_change_async(view, version, suppress_requests)) + await self.session.notify_plugin_on_session_buffer_change(self) + await self._on_after_change(view, version, suppress_requests) - def _on_after_change_async(self, view: sublime.View, version: int, suppress_requests: bool = False) -> None: + async def _on_after_change(self, view: sublime.View, version: int, suppress_requests: bool = False) -> None: if self._is_saving: self._has_changed_during_save = True return @@ -450,38 +486,38 @@ 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.create_task(self.do_document_diagnostic(view, version)) if request_flags & RequestFlags.SEMANTIC_TOKENS: - self.do_semantic_tokens_async(view) + self.create_task(self.do_semantic_tokens(view)) if userprefs().link_highlight_style == 'underline': self._do_document_link_async(view, version) if request_flags & RequestFlags.INLAY_HINT: - self.do_inlay_hints_async(view) - self.do_code_lenses_async(view) + self.create_task(self.do_inlay_hints(view)) + self.create_task(self.do_code_lenses(view)) except MissingUriError: pass - def on_pre_save_async(self, view: sublime.View) -> None: + async def on_pre_save(self, view: sublime.View) -> None: self._is_saving = True if self.should_notify_will_save(): - self.purge_changes_async(view) + await self.purge_changes(view) # TextDocumentSaveReason.Manual - self.session.send_notification(will_save(self._last_known_uri, TextDocumentSaveReason.Manual)) + await self.session.notify(will_save(self._last_known_uri, TextDocumentSaveReason.Manual)) - def on_post_save_async(self, view: sublime.View, new_uri: DocumentUri) -> None: + async def on_post_save(self, view: sublime.View, new_uri: DocumentUri) -> None: self._is_saving = False if new_uri != self._last_known_uri: - self._check_did_close(view) + await self._check_did_close(view) self._last_known_uri = new_uri - self._check_did_open(view) + await self._check_did_open(view) else: send_did_save, include_text = self.should_notify_did_save() if send_did_save: - self.purge_changes_async(view) - self.session.send_notification(did_save(view, include_text, self._last_known_uri)) + await self.purge_changes(view) + await self.session.notify(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()) + await self._on_after_change(view, view.change_count()) self.session.do_workspace_diagnostics_async() def on_userprefs_changed_async(self) -> None: @@ -524,11 +560,16 @@ 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], Coroutine[None, None, None] | 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: - f(view, *args, **kwargs) + if asyncio.iscoroutinefunction(f): + self.create_task(f(view, *args, **kwargs)) + else: + f(view, *args, **kwargs) return handler @@ -631,71 +672,70 @@ 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: + async def do_document_diagnostic( + self, view: sublime.View, version: int, *, forced_update: bool = False + ) -> list[BaseException | None]: mgr = self.session.manager() if not mgr or mgr.should_ignore_diagnostics(self._last_known_uri, self.session.config): - return + return [] if version < view.change_count(): # 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): - self._do_document_diagnostic_async(view, identifier, version, forced_update=forced_update) + return [] + + task = asyncio.gather( + *( + self._do_document_diagnostic(view, identifier, version, forced_update=forced_update) + for identifier in self.session.diagnostics.get_identifiers(view) + ), + return_exceptions=True, + ) self._reset_pending_refresh(RequestFlags.DIAGNOSTIC) + return await task - 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: return - if pending_request := self._document_diagnostic_pending_requests.get(identifier): - if pending_request.version == version and not forced_update: + if pending := self._document_diagnostic_pending_requests.get(identifier): + if pending.version == version and not forced_update: return - self.session.cancel_request_async(pending_request.request_id) + if not pending.request.cancelled: + await pending.request.cancel() params: DocumentDiagnosticParams = {'textDocument': text_document_identifier(view)} if identifier: 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') - diagnostics = response['items'] if is_related_full_document_diagnostic_report(response) else None - self.session.handle_diagnostics_async(self._last_known_uri, identifier, version, diagnostics) - if related_documents := response.get('relatedDocuments'): - for uri, report in related_documents.items(): - uri = normalize_uri(uri) - self.session.diagnostics_result_ids[(uri, identifier)] = report.get('resultId') - diagnostics = report['items'] if is_full_document_diagnostic_report(report) else None - self.session.handle_diagnostics_async(uri, identifier, None, diagnostics) - - def _on_document_diagnostic_error_async( - self, view: sublime.View, identifier: DiagnosticsIdentifier, version: int, error: ResponseError - ) -> None: + req = self.session.request(Request.documentDiagnostic(params, view)) + self._document_diagnostic_pending_requests[identifier] = PendingDocumentDiagnosticRequest(version, req) + error: Error | None = None + response = await req + if isinstance(response, Error): + if response.code == LSPErrorCodes.ServerCancelled: + error = response + elif response.code != LSPErrorCodes.RequestCancelled: + debug(f"error loading diagnostics: {response}") + else: + self._diagnostics_versions[identifier] = version + self.session.diagnostics_result_ids[(self._last_known_uri, identifier)] = response.get('resultId') + diagnostics = response['items'] if is_related_full_document_diagnostic_report(response) else None + self.session.handle_diagnostics_async(self._last_known_uri, identifier, version, diagnostics) + if related_documents := response.get('relatedDocuments'): + for uri, report in related_documents.items(): + uri = normalize_uri(uri) + self.session.diagnostics_result_ids[(uri, identifier)] = report.get('resultId') + diagnostics = report['items'] if is_full_document_diagnostic_report(report) else None + self.session.handle_diagnostics_async(uri, identifier, None, diagnostics) 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 - ) + if error 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 / 1000.0) + if version == view.change_count(): + self.create_task(self._do_document_diagnostic(view, identifier, version)) # --- textDocument/publishDiagnostics ------------------------------------------------------------------------------ @@ -807,15 +847,9 @@ def _create_on_type_formatting_params_async( } return None - 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) - # --- textDocument/semanticTokens ---------------------------------------------------------------------------------- - def do_semantic_tokens_async(self, view: sublime.View, only_viewport: bool = False) -> None: + async 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"): @@ -823,51 +857,58 @@ def do_semantic_tokens_async(self, view: sublime.View, only_viewport: bool = Fal if not self._semantic_highlighting_supported_by_color_scheme: return if self.semantic_tokens.pending_response: - self.session.cancel_request_async(self.semantic_tokens.pending_response) + pending_response, self.semantic_tokens.pending_response = self.semantic_tokens.pending_response, None + await pending_response.cancel() self.semantic_tokens.view_change_count = view.change_count() - if only_viewport and self.has_capability("semanticTokensProvider.range"): - request = Request.semanticTokensRange({ - "textDocument": text_document_identifier(view), - "range": region_to_range(view, view.visible_region()) - }, view) - self.semantic_tokens.pending_response = self.session.send_request_async( - request, partial(self._on_semantic_tokens_viewport_async, view), self._on_semantic_tokens_error_async) - elif self.semantic_tokens.result_id and self.has_capability("semanticTokensProvider.full.delta"): - request = Request.semanticTokensFullDelta({ - "textDocument": text_document_identifier(view), - "previousResultId": self.semantic_tokens.result_id - }, view) - self.semantic_tokens.pending_response = self.session.send_request_async( - request, self._on_semantic_tokens_delta_async, self._on_semantic_tokens_error_async) - elif self.has_capability("semanticTokensProvider.full"): - request = Request.semanticTokensFull({ - "textDocument": text_document_identifier(view), - }, view) - self.semantic_tokens.pending_response = self.session.send_request_async( - request, self._on_semantic_tokens_async, self._on_semantic_tokens_error_async) - elif self.has_capability("semanticTokensProvider.range"): - request = Request.semanticTokensRange({ - "textDocument": text_document_identifier(view), - "range": entire_content_range(view) - }, view) - self.semantic_tokens.pending_response = self.session.send_request_async( - request, self._on_semantic_tokens_async, self._on_semantic_tokens_error_async) self._reset_pending_refresh(RequestFlags.SEMANTIC_TOKENS) - - def _on_semantic_tokens_async(self, response: SemanticTokens | None) -> None: - self.semantic_tokens.pending_response = None - if response: + try: + if only_viewport and self.has_capability("semanticTokensProvider.range"): + await self._do_semantic_tokens(view, Request.semanticTokensRange({ + "textDocument": text_document_identifier(view), + "range": region_to_range(view, view.visible_region()) + }, view), only_viewport) + elif self.semantic_tokens.result_id and self.has_capability("semanticTokensProvider.full.delta"): + await self._do_semantic_tokens_delta(Request.semanticTokensFullDelta({ + "textDocument": text_document_identifier(view), + "previousResultId": self.semantic_tokens.result_id + }, view)) + elif self.has_capability("semanticTokensProvider.full"): + await self._do_semantic_tokens(view, Request.semanticTokensFull({ + "textDocument": text_document_identifier(view), + }, view)) + elif self.has_capability("semanticTokensProvider.range"): + await self._do_semantic_tokens(view, Request.semanticTokensRange({ + "textDocument": text_document_identifier(view), + "range": entire_content_range(view) + }, view)) + except BaseException: + self.semantic_tokens.result_id = None + raise + + async def _do_semantic_tokens( + self, + view: sublime.View, + request: Request[SemanticTokensParams, SemanticTokens | None] + | Request[SemanticTokensRangeParams, SemanticTokens | None], + only_viewport: bool = False, + ) -> None: + self.semantic_tokens.pending_response = self.session.request(request) + if (response := await self.semantic_tokens.pending_response) and not isinstance(response, Error): + self.semantic_tokens.pending_response = None self.semantic_tokens.result_id = response.get("resultId") self.semantic_tokens.data = response["data"] self._draw_semantic_tokens_async() + if only_viewport: + # now request semantic tokens for the full file + self.create_task(self.do_semantic_tokens(view)) - 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 - - def _on_semantic_tokens_delta_async(self, response: SemanticTokens | SemanticTokensDelta | None) -> None: - self.semantic_tokens.pending_response = None - if response: + async def _do_semantic_tokens_delta( + self, + request: Request[SemanticTokensDeltaParams, SemanticTokens | SemanticTokensDelta | None] + ) -> None: + self.semantic_tokens.pending_response = self.session.request(request) + if (response := await self.semantic_tokens.pending_response) and not isinstance(response, Error): + self.semantic_tokens.pending_response = None self.semantic_tokens.result_id = response.get("resultId") if "edits" in response: # response is of type SemanticTokensDelta for semantic_tokens_edit in response["edits"]: @@ -881,10 +922,6 @@ def _on_semantic_tokens_delta_async(self, response: SemanticTokens | SemanticTok self.semantic_tokens.data = response["data"] self._draw_semantic_tokens_async() - def _on_semantic_tokens_error_async(self, _: ResponseError) -> None: - self.semantic_tokens.pending_response = None - self.semantic_tokens.result_id = None - def _draw_semantic_tokens_async(self) -> None: view = self.some_view() if view is None: @@ -954,7 +991,7 @@ def clear_semantic_tokens_async(self) -> None: # --- textDocument/inlayHint ---------------------------------------------------------------------------------- - def do_inlay_hints_async(self, view: sublime.View) -> None: + async def do_inlay_hints(self, view: sublime.View) -> None: if not self.has_capability("inlayHintProvider"): return window = view.window() @@ -967,14 +1004,10 @@ def do_inlay_hints_async(self, view: sublime.View) -> None: "textDocument": text_document_identifier(view), "range": entire_content_range(view) } - self.session.send_request_async(Request.inlayHint(params, view), self._on_inlay_hints_async) self._reset_pending_refresh(RequestFlags.INLAY_HINT) - - def _on_inlay_hints_async(self, response: list[InlayHint] | None) -> None: - if response: - view = self.some_view() - if not view: - return + if (response := await self.session.request(Request.inlayHint(params, view))) and not isinstance( + response, Error + ): phantoms = [inlay_hint_to_phantom(view, inlay_hint, self.session) for inlay_hint in response] sublime.set_timeout(lambda: self.present_inlay_hints(phantoms)) else: @@ -995,6 +1028,7 @@ def remove_all_inlay_hints(self) -> None: # --- textDocument/codeAction -------------------------------------------------------------------------------------- + @deprecated("use SessionBuffer.request_code_actions instead") def request_code_actions_async( self, view: sublime.View, @@ -1005,6 +1039,20 @@ def request_code_actions_async( *, progress: bool = False, ) -> Promise[list[Command | CodeAction] | Error | None]: + return self.create_task_and_wrap_in_promise( + self.request_code_actions(view, region, diagnostics, kinds, trigger_kind, progress=progress) + ) + + async def request_code_actions( + self, + view: sublime.View, + region: sublime.Region, + diagnostics: list[Diagnostic], + kinds: list[str | CodeActionKind] | None = None, + trigger_kind: CodeActionTriggerKind = CodeActionTriggerKind.Automatic, + *, + progress: bool = False, + ) -> list[Command | CodeAction] | Error | None: context: CodeActionContext = { 'diagnostics': diagnostics, 'triggerKind': trigger_kind @@ -1016,38 +1064,45 @@ def request_code_actions_async( 'range': region_to_range(view, region), 'context': context } - request = Request.codeAction(params, view, progress=progress) - return self.session.send_request_task(request) + return await self.session.request(Request.codeAction(params, view, progress=progress)) # --- textDocument/codeLens ---------------------------------------------------------------------------------------- - def do_code_lenses_async(self, view: sublime.View) -> None: + async def do_code_lenses(self, view: sublime.View) -> None: if not self.has_capability('codeLensProvider'): return if not LspToggleCodeLensesCommand.are_enabled(view.window()): return for sv in self.session_views: if sv.view == view: - for request_id, data in sv.active_requests.items(): - if data.request.method == 'codeLens/resolve': - self.session.cancel_request_async(request_id) + await asyncio.gather( + *( + data.cancel() + for data in sv.active_requests.values() + if data.request.method == 'codeLens/resolve' + ) + ) break - request = Request('textDocument/codeLens', {'textDocument': text_document_identifier(view)}, view) - self.session.send_request_async(request, partial(self._on_code_lenses_async, view)) self._reset_pending_refresh(RequestFlags.CODE_LENS) + code_lenses = await self.session.request( + Request('textDocument/codeLens', {'textDocument': text_document_identifier(view)}, view) + ) + if not isinstance(code_lenses, Error): + self._code_lenses.handle_response_async(code_lenses or []) + await self.resolve_visible_code_lenses(view) - def _on_code_lenses_async(self, view: sublime.View, response: list[CodeLens] | None) -> None: - self._code_lenses.handle_response_async(response or []) - self.resolve_visible_code_lenses_async(view) - - def resolve_visible_code_lenses_async(self, view: sublime.View) -> None: - promises: list[Promise[None]] = [] + async def resolve_visible_code_lenses(self, view: sublime.View) -> None: if self.has_capability('codeLensProvider.resolveProvider'): - for code_lens in self._code_lenses.unresolved_visible_code_lenses(view): - request = Request('codeLens/resolve', code_lens.data, view) - promise = self.session.send_request_task(request).then(code_lens.on_resolve) - promises.append(promise) - Promise.all(promises).then(lambda _: self._on_visible_code_lenses_resolved_async()) + _ = await asyncio.gather( + *( + code_lens.resolve(self.session, view) + for code_lens in self._code_lenses.unresolved_visible_code_lenses(view) + ), + return_exceptions=True, + ) + supported_code_lenses = self._filter_supported_code_lenses() + for sv in self.session_views: + sv.handle_code_lenses_async(supported_code_lenses) def _filter_supported_code_lenses(self) -> list[ResolvedCodeLens]: code_lenses_with_command = self._code_lenses.code_lenses_with_command() @@ -1066,11 +1121,6 @@ def _filter_supported_code_lenses(self) -> list[ResolvedCodeLens]: self.session.check_log_unsupported_command(command_name) return supported_code_lenses - def _on_visible_code_lenses_resolved_async(self) -> None: - supported_code_lenses = self._filter_supported_code_lenses() - for sv in self.session_views: - sv.handle_code_lenses_async(supported_code_lenses) - # ------------------------------------------------------------------------------------------------------------------ def __str__(self) -> str: diff --git a/plugin/session_view.py b/plugin/session_view.py index 6bef5477b..283200b57 100644 --- a/plugin/session_view.py +++ b/plugin/session_view.py @@ -22,9 +22,11 @@ from .diagnostics import DiagnosticsAnnotationsView from .session_buffer import SessionBuffer from typing import Any +from typing import Coroutine from typing import TYPE_CHECKING from weakref import ref from weakref import WeakValueDictionary +import asyncio import html import itertools import sublime @@ -33,6 +35,7 @@ from .core.protocol import Request from .core.protocol import ResolvedCodeLens from .core.sessions import AbstractViewListener + from .core.sessions import RequestController from .core.sessions import Session @@ -84,7 +87,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() @@ -93,10 +96,8 @@ def on_before_remove(self) -> None: # If the session is exiting then there's no point in sending textDocument/didClose and there's also no point # in unregistering ourselves from the session. if not self.session.exiting: - 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 asyncio.gather(*(data.cancel() for data in self._active_requests.values())) + 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 +105,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 +291,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) @@ -348,8 +351,8 @@ def _draw_diagnostics( else: self.view.erase_regions(data.key) - def on_request_started_async(self, request_id: int, request: Request[Any, Any]) -> None: - self._active_requests[request_id] = ActiveRequest(self, request_id, request) + def on_request_started_async(self, controller: RequestController, request: Request[Any, Any]) -> None: + self._active_requests[controller.id] = ActiveRequest(self, controller, request) def on_request_finished_async(self, request_id: int) -> None: self._active_requests.pop(request_id, None) @@ -362,25 +365,25 @@ 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) + def on_revert(self) -> Coroutine[None, None, None]: + return self.session_buffer.on_revert(self.view) - def on_reload_async(self) -> None: - self.session_buffer.on_reload_async(self.view) + def on_reload(self) -> Coroutine[None, None, None]: + return self.session_buffer.on_reload(self.view) - def purge_changes_async(self) -> None: - self.session_buffer.purge_changes_async(self.view) + def purge_changes(self) -> Coroutine[None, None, None]: + return self.session_buffer.purge_changes(self.view) - def on_pre_save_async(self) -> None: - self.session_buffer.on_pre_save_async(self.view) + def on_pre_save(self) -> Coroutine[None, None, None]: + return self.session_buffer.on_pre_save(self.view) - def on_post_save_async(self, new_uri: DocumentUri) -> None: - self.session_buffer.on_post_save_async(self.view, new_uri) + def on_post_save(self, new_uri: DocumentUri) -> Coroutine[None, None, None]: + return self.session_buffer.on_post_save(self.view, new_uri) def on_userprefs_changed_async(self) -> None: self._redraw_diagnostics_async() diff --git a/plugin/symbols.py b/plugin/symbols.py index 45cd7ad01..b7d14881f 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 from .core.constants import SYMBOL_KINDS from .core.input_handlers import DynamicListInputHandler from .core.input_handlers import PreselectedListInputHandler @@ -328,25 +329,24 @@ class LspWorkspaceSymbolsCommand(LspWindowCommand): capability = 'workspaceSymbolProvider' def run(self, symbol: WorkspaceSymbolValue) -> None: + run_coroutine(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)) + if not isinstance(workspace_symbol, Error): + 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/plugin/tooling.py b/plugin/tooling.py index 3faaeeb64..67fadb810 100644 --- a/plugin/tooling.py +++ b/plugin/tooling.py @@ -4,11 +4,14 @@ from .api import LspPlugin from .api import OnPreStartContext from .api import PluginStartError +from .core.aio import run_coroutine +from .core.aio import run_on_threadpool 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 +24,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 +330,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(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 +348,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 +365,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 +496,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, {}) + 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) + await plugin_class.on_pre_start(context) + cwd = context.working_directory else: if plugin_class.needs_update_or_installation(): - plugin_class.install_or_update() + # Historically these methods tended to run relatively slow. + # We don't want to use Sublime's worker thread for this any longer. + # Utilize the default thread pool instead. + # https://docs.python.org/3/library/asyncio-dev.html#running-blocking-code + run_on_threadpool(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 +554,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)) diff --git a/stubs/sublime_aio.pyi b/stubs/sublime_aio.pyi new file mode 100644 index 000000000..e09ee6f82 --- /dev/null +++ b/stubs/sublime_aio.pyi @@ -0,0 +1,86 @@ +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, TypeVarTuple, Unpack + +__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') +Ts = TypeVarTuple('Ts') +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 run_in_worker(func: Callable[[Unpack[Ts]], T], *args: Unpack[Ts]) -> asyncio.Future[T]: ... +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]: ... diff --git a/tests/async_test_case.py b/tests/async_test_case.py new file mode 100644 index 000000000..624d56016 --- /dev/null +++ b/tests/async_test_case.py @@ -0,0 +1,128 @@ +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 + + +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 + + @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=cls.timeout_ms / 1000, return_when=asyncio.FIRST_COMPLETED) + if task in pending: + print("\n=== BEGIN: COROUTINE STACK BEFORE CANCELLATION ===") + task.print_stack() + print("=== END: COROUTINE STACK BEFORE CANCELLATION ===") + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + raise TimeoutError + await task + + future = cls.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": 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: + yield from cls._runCoro(cls.asyncSetUpClass()) + + @override + @classmethod + def tearDownClass(cls) -> Generator: + yield from cls._runCoro(cls.asyncTearDownClass()) + + @override + def doCleanups(self) -> Generator: + yield from self._runCoro(self.asyncDoCleanups()) + + @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/server.py b/tests/server.py index 328476d34..27744de41 100644 --- a/tests/server.py +++ b/tests/server.py @@ -330,6 +330,7 @@ def _install_handlers(self) -> None: self._on_request("$test/fakeRequest", self._fake_request) self._on_request("$test/sendNotification", self._send_notification) self._on_request("$test/setResponses", self._set_responses) + self._on_request("$test/getAndClearUnusedMockResponses", self._get_and_clear_unused_mock_responses) self._on_notification("$test/setResponse", self._on_set_response) async def _on_set_response(self, params: PayloadLike) -> None: @@ -347,6 +348,11 @@ async def _set_responses(self, params: PayloadLike) -> PayloadLike: self._responses.extend([(param['method'], param['response']) for param in params]) return None + async def _get_and_clear_unused_mock_responses(self, params: PayloadLike) -> PayloadLike: + responses = list(self._responses) + self._responses = [] + return responses + async def _send_notification(self, params: PayloadLike) -> PayloadLike: method, payload = self._validate_request_params(params) self._notify(method, payload) diff --git a/tests/setup.py b/tests/setup.py index 0f51bfca0..263c21251 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -1,25 +1,39 @@ from __future__ import annotations +from .async_test_case import AsyncTestCase +from .async_test_case import FutureLike from .test_mocks import basic_responses +from functools import partial +from LSP.plugin.core.aio import run_coroutine +from LSP.plugin.core.aio import run_on_asyncio_thread +from LSP.plugin.core.aio import tick from LSP.plugin.core.collections import DottedDict +from LSP.plugin.core.open import open_file +from LSP.plugin.core.protocol import Error 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 Callable +from typing import Coroutine from typing import TYPE_CHECKING -from unittesting import DeferrableTestCase +from typing_extensions import override +import asyncio import sublime if TYPE_CHECKING: - from collections.abc import Generator - from LSP.plugin.core.promise import Promise + from LSP.plugin.core.sessions import CancellableRequest + from LSP.plugin.core.sessions import Session + from LSP.plugin.core.windows import WindowManager + from LSP.protocol import CodeAction + from LSP.protocol import LSPAny CI = any(key in environ for key in ("TRAVIS", "CI", "GITHUB_ACTIONS")) @@ -27,24 +41,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( @@ -94,57 +90,102 @@ 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: + while view.is_loading(): # noqa: ASYNC110 + await asyncio.sleep(0.05) view.set_scratch(True) - yield {"condition": lambda: not view.is_loading(), "timeout": TIMEOUT_TIME} - view.close() + future = asyncio.get_running_loop().create_future() + view.close(partial(run_on_asyncio_thread, future.set_result)) + await future 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(coro) + + +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") + @override @classmethod - def setUpClass(cls) -> Generator: - super().setUpClass() + async def asyncSetUpClass(cls) -> None: 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) - yield from close_test_view(open_view) + 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) - 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: + 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(): + 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()}") + cls.initialize_params = await cls.await_message("initialize") + await cls.await_message("initialized") + + @override + async def setUp(self) -> None: 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)) + if view := await open_file(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 - params = yield from self.await_message("textDocument/didOpen") - self.assertEqual(params["textDocument"]["version"], 0) + 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) + version: int = params["textDocument"]["version"] + if version != 0: + print( + f"WARNING: for some reason, the document version of {filename} is {version}. Attempting to close and then re-open it..." # noqa: E501 + ) + await asyncio.sleep(0.2) + await close_test_view(self.__class__.view) + await asyncio.sleep(0.2) + if view := await open_file(window, filename_to_uri(filename)): + self.__class__.view = view + else: + raise AssertionError(f"unable to open file {filename}") + + async def tearDown(self) -> None: + self.assertIsNotNone(self.session) + assert self.session + for response in await self.get_and_clear_unused_mock_responses(): + print(f"WARNING: unused mock response: {response}") @classmethod def get_test_name(cls) -> str: @@ -154,9 +195,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", []) @@ -166,7 +207,7 @@ def init_view_settings(cls) -> None: s("lsp_format_on_save", False) @classmethod - def ensure_document_listener_created(cls) -> bool: + 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 @@ -174,12 +215,17 @@ def ensure_document_listener_created(cls) -> bool: # 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 + return listener + return None + + @staticmethod + async def wait_until(condition: Callable[[], bool]) -> None: + """Returns when the given state has been reached.""" + while not condition(): + await tick() @classmethod - def await_message(cls, method: str, promise: YieldPromise | None = None) -> Generator[Any, None, Any]: + def await_message(cls, method: str) -> CancellableRequest[LSPAny]: """ Awaits until server receives a request with a specified method. @@ -188,130 +234,76 @@ 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 + return cls.session.request(Request("$test/getReceived", {"method": method})) - def make_server_do_fake_request(self, method: str, params: Any) -> YieldPromise: - promise = YieldPromise() - - def on_result(params: Any) -> None: - promise.fulfill(params) - - def on_error(params: Any) -> None: - promise.fulfill(params) - - req = Request("$test/fakeRequest", {"method": method, "params": params}) - self.session.send_request(req, on_result, on_error) - return promise + @classmethod + def make_server_do_fake_request(cls, method: str, params: LSPAny) -> CancellableRequest[LSPAny]: + """Make the fake server do an arbitrary request.""" + assert cls.session + return cls.session.request(Request("$test/fakeRequest", {"method": method, "params": params})) @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) + async def await_run_code_action(cls, code_action: CodeAction) -> LSPAny | Error: + assert cls.session + return await cls.session.run_code_action(code_action, progress=False, view=cls.view) - 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) - - def error_handler(params: Any) -> None: - print("Got error:", params, "awaiting timeout :(") + await self.session.request(Request("$test/sendNotification", {"method": method, "params": params})) + return params - req = Request("$test/sendNotification", {"method": method, "params": params}) - self.session.send_request(req, handler, error_handler) - yield from self.await_promise(promise) + async def get_and_clear_unused_mock_responses(self) -> list[tuple[str, LSPAny]]: + return await self.session.request(Request("$test/getAndClearUnusedMockResponses")) - 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} + await self.wait_until(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() + @override @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: + async def asyncTearDownClass(cls) -> None: + try: + if cls.session and cls.wm: + await cls.session.end() + finally: + # restore the user's configs + remove_config(cls.config) + await super().asyncTearDownClass() + + @override + async def asyncDoCleanups(self) -> None: if self.view and self.view.is_valid(): - yield from close_test_view(self.view) - yield from super().doCleanups() + await close_test_view(self.view) diff --git a/tests/test_code_actions.py b/tests/test_code_actions.py index 8da252a12..1be3aa914 100644 --- a/tests/test_code_actions.py +++ b/tests/test_code_actions.py @@ -14,19 +14,23 @@ 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 Generator +from LSP.protocol import CodeActionTriggerKind from typing import TYPE_CHECKING 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 + from typing import Any 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 asyncDoCleanups(self) -> None: + await self.await_clear_view_and_save() + await super().asyncDoCleanups() 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,80 +138,160 @@ 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: code_action_kind = 'source.fixAll' - code_action = create_test_code_action( - self.view, - self.view.change_count(), - [(';', range_from_points(Point(0, 11), Point(0, 11)))], - code_action_kind + # The first textDocument/codeAction request should be due to the text change. + await self.mock_response( + 'textDocument/codeAction', + [ + create_test_code_action( + self.view, + self.view.change_count(), + [(';', range_from_points(Point(0, 11), Point(0, 11)))], + code_action_kind, + ) + ], + ) + await self._setup_document_with_missing_semicolon() + params = await self.await_message('textDocument/codeAction') + self.assertEqual(params['context']['triggerKind'], CodeActionTriggerKind.Automatic) + self.assertEqual(params['context']['only'], ['quickfix']) + self.assertEqual( + params['range'], {'start': {'line': 0, 'character': 11}, 'end': {'line': 0, 'character': 11}} + ) + + # The second textDocument/codeAction request should be due to saving. + await self.mock_response( + 'textDocument/codeAction', + [ + create_test_code_action( + self.view, + self.view.change_count(), + [(';', range_from_points(Point(0, 11), Point(0, 11)))], + code_action_kind, + ) + ], ) - self.set_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') + params = await self.await_message('textDocument/codeAction') + self.assertEqual(params['context']['triggerKind'], CodeActionTriggerKind.Automatic) + self.assertEqual(params['context']['only'], ['source.fixAll']) + self.assertEqual( + params['range'], {'start': {'line': 0, 'character': 0}, 'end': {'line': 0, 'character': 11}} + ) + params = 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: code_action_kind = 'source.fixAll' - code_action = create_test_code_action( - self.view, - self.view.change_count(), - [(';', range_from_points(Point(0, 11), Point(0, 11)))], - code_action_kind + # The first textDocument/codeAction request should be due to the text change. + await self.mock_response( + 'textDocument/codeAction', + [ + create_test_code_action( + self.view, + self.view.change_count(), + [(';', range_from_points(Point(0, 11), Point(0, 11)))], + code_action_kind, + ) + ], + ) + await self._setup_document_with_missing_semicolon() + params = await self.await_message('textDocument/codeAction') + self.assertEqual(params['context']['triggerKind'], CodeActionTriggerKind.Automatic) + self.assertEqual(params['context']['only'], ['quickfix']) + self.assertEqual(params['range'], {'start': {'line': 0, 'character': 11}, 'end': {'line': 0, 'character': 11}}) + + # The second textDocument/codeAction request should be due to saving. + await self.mock_response( + 'textDocument/codeAction', + [ + create_test_code_action( + self.view, + self.view.change_count(), + [(';', range_from_points(Point(0, 11), Point(0, 11)))], + code_action_kind, + ) + ], ) - self.set_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.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: - self.insert_characters('const x = 1') + async def test_applies_only_one_pass(self) -> None: initial_change_count = self.view.change_count() - yield from self.await_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([ - ( - 'textDocument/codeAction', - [ - create_test_code_action( - self.view, - initial_change_count, - [(';', range_from_points(Point(0, 11), Point(0, 11)))], - code_action_kind - ) - ] - ), - ( - 'textDocument/codeAction', + should_be_unused_code_actions = [ + create_test_code_action( + self.view, + initial_change_count + 2, + [('\nAnd again!', range_from_points(Point(0, 12), Point(0, 12)))], + code_action_kind, + ) + ] + await self.mock_responses( + [ + ( + # This first one is for the initial code actions request when the text changes. + 'textDocument/codeAction', + [ + create_test_code_action( + self.view, + initial_change_count + 1, + [(';', range_from_points(Point(0, 11), Point(0, 11)))], + code_action_kind, + ) + ], + ), + ( + # These last two are for the on-save tasks. + # The first one has a matching document version, so it will apply. + # The second one, with the '\nAnd again!' text change, also has a matching document version! + # But, on-save tasks should only do *one pass*. Not more passes. + 'textDocument/codeAction', + [ + create_test_code_action( + self.view, + initial_change_count + 1, + [(';', range_from_points(Point(0, 11), Point(0, 11)))], + code_action_kind, + ) + ], + ), + ( + # This one should NOT be requested, because on-save tasks should only do one pass. + 'textDocument/codeAction', + should_be_unused_code_actions, + ), + ] + ) + self.insert_characters('const x = 1') + await self.mock_client_notification( + "textDocument/publishDiagnostics", + create_test_diagnostics( [ - create_test_code_action( - self.view, - initial_change_count + 1, - [('\nAnd again!', range_from_points(Point(0, 12), Point(0, 12)))], - code_action_kind - ) + ('Missing semicolon', range_from_points(Point(0, 11), Point(0, 11))), ] ), - ]) + ) + + # Save the file, check that the COAS was applied. self.view.run_command('lsp_save', {'async': True}) - # Wait for the view to be saved - yield lambda: not self.view.is_dirty() + await self.wait_until(lambda: not self.view.is_dirty()) self.assertEqual(entire_content(self.view), 'const x = 1;') - def test_applies_immediately_after_text_change(self) -> Generator: + # Check that the last mock response was NOT requested. + unused_mock_responses = await self.get_and_clear_unused_mock_responses() + self.assertEqual(len(unused_mock_responses), 1) + self.assertEqual(unused_mock_responses[0][0], 'textDocument/codeAction') + self.assertEqual(unused_mock_responses[0][1], should_be_unused_code_actions) + + 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 +300,35 @@ 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: + code_action_kind = 'some.non.matching.kind.that.does.not.exist' + await self.mock_response( + 'textDocument/codeAction', + [ + create_test_code_action( + self.view, + self.view.change_count(), + [(';', range_from_points(Point(0, 11), Point(0, 11)))], + code_action_kind, + ) + ], + ) + 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 +336,18 @@ 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]) + # First one is for the text changes. + await self.mock_response('textDocument/codeAction', [code_action]) + # Second one is for the on-save actions. + 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 +356,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 +371,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 +382,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 +402,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 +413,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 +424,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 +518,14 @@ def test_kind_matching(self) -> None: class CodeActionsListenerTestCase(TextDocumentTestCase): - def setUp(self) -> Generator: - yield from super().setUp() + async def setUp(self) -> None: + await super().setUp() self.original_debounce_time = DocumentSyncListener.debounce_time DocumentSyncListener.debounce_time = 0 - def tearDown(self) -> None: + async def tearDown(self) -> None: DocumentSyncListener.debounce_time = self.original_debounce_time - super().tearDown() + await super().tearDown() @classmethod def get_test_server_capabilities(cls) -> dict: @@ -438,39 +533,60 @@ def get_test_server_capabilities(cls) -> dict: capabilities['capabilities']['codeActionProvider'] = {} return capabilities - def test_requests_with_diagnostics(self) -> Generator: + async def test_requests_with_diagnostics(self) -> None: initial_content = 'a\nb\nc' 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)) 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( + 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]) + + # Publish some fake diagnostics. + await self.mock_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') + + # The published diagnostics should cause a code action request. + # Since the caret is at the 'c' character, the context should only contain 'issue c'. + params = await self.await_message('textDocument/codeAction') + self.assertEqual(len(params['context']['diagnostics']), 1) + self.assertEqual(params['context']['diagnostics'][0]['message'], 'issue c') + + # Set up another mock response for a code action request from the client. + await self.mock_response('textDocument/codeAction', [code_action_a, code_action_b]) + + # Select a and b. + self.view.run_command('lsp_selection_set', {"regions": [(0, 3)]}) + await self.wait_until( + lambda: len(self.view.sel()) == 1 and self.view.sel()[0].a == 0 and self.view.sel()[0].b == 3 + ) + + # The change in selection should cause another code action request from the client. + params = await self.await_message('textDocument/codeAction') + + # This time, the context should contain 'issue a' and 'issue b' due to the selection change. + self.assertEqual(len(params['context']['diagnostics']), 2) + self.assertEqual(params['context']['diagnostics'][0]['message'], 'issue a') + self.assertEqual(params['context']['diagnostics'][1]['message'], 'issue b') 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) + await self.wait_until(lambda: len(self.view.get_regions(RegionKey.CODE_ACTION)) == 1) 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_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 +595,9 @@ 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 self.await_message('textDocument/codeAction') code_action_ranges = self.view.get_regions(RegionKey.CODE_ACTION) self.assertEqual(len(code_action_ranges), 0) @@ -495,70 +610,81 @@ 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: + # Set up a mock response so we don't get an exception. + await self.mock_response( + 'textDocument/codeAction', + [ + create_disabled_code_action( + self.view, self.view.change_count(), [(';', range_from_points(Point(0, 0), Point(0, 1)))] + ) + ], + ) 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..918e6e0fc 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,56 @@ 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: + await self.wait_until(self.view.is_auto_complete_visible) - 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) + await self.wait_until(lambda: self.view.change_count() > current_change_count) - 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.set_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.set_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 +122,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 +161,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 +169,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 +191,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 +219,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 +251,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 +284,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 +315,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 +331,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 +353,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.set_response("completionItem/resolve", { + await self.mock_response("completionItem/resolve", { 'label': 'asdf', 'additionalTextEdits': [ { @@ -385,13 +375,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.set_response( + async def test_prefix_should_include_the_dollar_sign(self) -> None: + await self.mock_response( 'textDocument/completion', { "items": @@ -415,13 +405,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 +420,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 +432,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 +442,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 +454,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 +468,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.set_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 +490,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 +507,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.set_response("textDocument/completion", [{ + await self.mock_response("textDocument/completion", [{ 'filterText': 'uchar.h>', 'label': ' uchar.h>', 'textEdit': { @@ -532,14 +522,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.set_response("textDocument/completion", [{ + await self.mock_response("textDocument/completion", [{ 'filterText': 'uchar.h>', 'label': ' uchar.h>', 'textEdit': { @@ -552,23 +542,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.set_response("textDocument/completion", [{ + await self.mock_response("textDocument/completion", [{ 'kind': 10, 'documentation': 'Array of single or multiple keys', 'insertTextFormat': 2, @@ -582,21 +572,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: self.type("\t\n\t") self.move_cursor(1, 2) - self.set_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 +594,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.set_response("textDocument/completion", [{ + await self.mock_response("textDocument/completion", [{ 'label': 'title', 'textEdit': { 'newText': 'title', @@ -620,14 +610,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.set_response("textDocument/completion", [{ + await self.mock_response("textDocument/completion", [{ 'label': 'turtle', 'textEdit': { 'newText': 'turtle', @@ -635,8 +625,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 +647,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 +656,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 +766,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 +785,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 48e57b410..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.await_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( + 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.await_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.await_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.await_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.await_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.await_client_notification( + await self.await_message("textDocument/didChange") + await self.mock_client_notification( "textDocument/publishDiagnostics", { "uri": TEST_FILE_URI, diff --git a/tests/test_documents.py b/tests/test_documents.py index 14f78d516..7ccb12a7e 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -7,24 +7,22 @@ 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.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 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: + 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 @@ -32,11 +30,11 @@ def ensure_document_listener_created(self) -> bool: # 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 + return listener + return None - def setUp(self) -> Generator: + @override + async def setUp(self) -> None: initialization_options = { "serverResponse": { "capabilities": { @@ -57,6 +55,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 +64,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 = 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 +120,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_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 9ae6bf07d..1fe65de38 100644 --- a/tests/test_file_watcher.py +++ b/tests/test_file_watcher.py @@ -13,7 +13,6 @@ 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 import sublime import sys @@ -89,24 +88,24 @@ class FileWatcherDocumentTestCase(TextDocumentTestCase): """ @classmethod - def setUpClass(cls) -> None: + async def asyncSetUpClass(cls) -> None: # Don't call the superclass. register_file_watcher_implementation(TestFileWatcher) @classmethod - def tearDownClass(cls) -> None: + async def asyncTearDownClass(cls) -> None: # Don't call the superclass. pass - def setUp(self) -> Generator: + 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() - yield from super().setUpClass() - yield from super().setUp() + await super().asyncSetUpClass() + await super().setUp() - def tearDown(self) -> Generator: - yield from super().tearDownClass() + async def tearDown(self) -> None: + await super().asyncTearDownClass() self.assertEqual(len(TestFileWatcher.active_watchers), 0) # Restore original project data. window = sublime.active_window() @@ -140,11 +139,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] @@ -155,7 +155,7 @@ def test_handles_file_event(self) -> Generator: @unittest.skipIf(sys.platform == 'darwin', 'FileWatcherDynamicTests are failing on latest github macOS runners') class FileWatcherDynamicTests(FileWatcherDocumentTestCase): - def test_handles_dynamic_watcher_registration(self) -> Generator: + async def test_handles_dynamic_watcher_registration(self) -> None: registration_params = { 'registrations': [ { @@ -172,7 +172,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']) @@ -181,7 +181,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] @@ -191,7 +192,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': [ { @@ -232,7 +233,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']) @@ -243,7 +244,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 = { @@ -273,7 +274,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 7802e7a03..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.await_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 fab4ad491..293fcdf3b 100644 --- a/tests/test_server_requests.py +++ b/tests/test_server_requests.py @@ -1,12 +1,12 @@ 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 typing import Any -from typing import Generator from typing import TYPE_CHECKING import os import sublime @@ -24,24 +24,41 @@ 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, + expected_error_code: ErrorCodes | None = None, +) -> None: + result = await testcase.make_server_do_fake_request(method, input_params) + if isinstance(result, Error): + if expected_error_code is not None: + testcase.assertEqual(result.code, expected_error_code) + else: + testcase.fail(f"method {method} returned error {result}") + else: + testcase.assertEqual(result, expected_output_params) class ServerRequests(TextDocumentTestCase): + async def test_unknown_method(self) -> None: + await verify( + self, + "foobar/qux", + {}, + {"code": ErrorCodes.MethodNotFound, "message": "foobar/qux"}, + ErrorCodes.MethodNotFound, + ) - def test_unknown_method(self) -> Generator: - yield from 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) @@ -51,11 +68,11 @@ 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_client_registerCapability(self) -> Generator: - yield from verify( + async def test_m_client_registerCapability(self) -> None: + await verify( self, "client/registerCapability", { @@ -88,8 +105,9 @@ def test_m_client_registerCapability(self) -> Generator: # willSaveWaitUntil is *only* registered on the buffer self.assertFalse(self.session.capabilities.get("textDocumentSync.willSaveWaitUntil")) + await self.wait_until(lambda: len(list(self.session.session_buffers_async())) > 0) sb = next(self.session.session_buffers_async()) - self.assertEqual(sb.capabilities.text_sync_kind(), TextDocumentSyncKind.Full) + await self.wait_until(lambda: 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) @@ -97,18 +115,18 @@ def test_m_client_registerCapability(self) -> Generator: # characters for each view were updated self.assertEqual(sb.capabilities.get("completionProvider.id"), "myCompletionRegistrationId") self.assertEqual(sb.capabilities.get("completionProvider.triggerCharacters"), ["!", "@", "#"]) + await self.wait_until(lambda: get_auto_complete_trigger(sb) is not None) trigger = get_auto_complete_trigger(sb) - 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"}]}, @@ -132,8 +150,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 9048f668d..bb496c4aa 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 @@ -50,24 +49,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..d70f3e68a 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -1,20 +1,21 @@ 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 from LSP.plugin import Request 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 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 +58,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 +77,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 +105,22 @@ 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 + await self.wait_until(self.view.is_popup_visible) - 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 +141,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 +163,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 +183,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 +234,35 @@ def condition() -> bool: return False return s[0].begin() > 0 - yield {"condition": condition, "timeout": TIMEOUT_TIME} + await self.wait_until(condition) 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 +278,19 @@ 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") + await self.wait_until(lambda: self.view.sel()[0] == sublime.Region(a, b)) - 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 +316,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"}) + 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.run_command(command, progress=False) + await self.await_message("workspace/executeCommand") + self.assertEqual(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) - self.assertEqual(result, {"general": "kenobi"}) + async def test_progress(self) -> None: + # 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(await 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 = 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 {'rangeLength': 0, 'range': {'start': {'line': 0, 'character': 2}, 'end': {'line': 0, 'character': 2}}, 'text': '\n'}, # noqa @@ -377,7 +370,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 +379,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 +392,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 +405,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() diff --git a/tests/test_workspace_edit.py b/tests/test_workspace_edit.py index e49f96fae..77fea2fa0 100644 --- a/tests/test_workspace_edit.py +++ b/tests/test_workspace_edit.py @@ -1,30 +1,47 @@ +# ruff: noqa: ASYNC240 + from __future__ import annotations +from .setup import CI from .setup import TextDocumentTestCase +from LSP.plugin import Error from LSP.plugin.core.url import filename_to_uri from LSP.plugin.core.views import entire_content from pathlib import Path from typing import Any -from typing import Generator from typing import TYPE_CHECKING import os +import sys import tempfile +import unittest if TYPE_CHECKING: from ..protocol import ApplyWorkspaceEditParams from ..protocol import ApplyWorkspaceEditResult from ..protocol import WorkspaceEdit + from LSP.protocol import ErrorCodes -def verify(testcase: TextDocumentTestCase, method: str, input_params: Any, expected_result: Any) -> Generator: - promise = testcase.make_server_do_fake_request(method, input_params) - yield from testcase.await_promise(promise) - testcase.assertEqual(promise.result(), expected_result) +async def verify( + testcase: TextDocumentTestCase, + method: str, + input_params: Any, + expected_output_params: Any, + expected_error_code: ErrorCodes | None = None, +) -> None: + result = await testcase.make_server_do_fake_request(method, input_params) + if isinstance(result, Error): + if expected_error_code is not None: + testcase.assertEqual(result.code, expected_error_code) + else: + testcase.fail(f"method {method} returned error {result}") + else: + testcase.assertEqual(result, expected_output_params) class ApplyWorkspaceEditTests(TextDocumentTestCase): - def test_changes(self) -> Generator: + async def test_changes(self) -> None: old_change_count = self.insert_characters('hello\nworld\n') uri = filename_to_uri(self.view.file_name()) workspace_edit: WorkspaceEdit = { @@ -39,13 +56,13 @@ def test_changes(self) -> Generator: } params: ApplyWorkspaceEditParams = {'edit': workspace_edit} expected_result: ApplyWorkspaceEditResult = {'applied': True} - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) # `changes` should increase the document version self.assertTrue(self.view.change_count() > old_change_count) # `changes` should have been applied self.assertEqual(entire_content(self.view), 'hello\nthere\n') - def test_document_changes(self) -> Generator: + async def test_document_changes(self) -> None: uri = filename_to_uri(self.view.file_name()) version = self.insert_characters('hello\nworld\n') workspace_edit: WorkspaceEdit = { @@ -63,13 +80,13 @@ def test_document_changes(self) -> Generator: } params: ApplyWorkspaceEditParams = {'edit': workspace_edit} expected_result: ApplyWorkspaceEditResult = {'applied': True} - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) # `documentChanges` should increase the document version by exactly 1 self.assertEqual(self.view.change_count(), version + 1) # `documentChanges` should have been applied self.assertEqual(entire_content(self.view), 'hello\nthere\n') - def test_changes_for_unopened_files(self) -> Generator: + async def test_changes_for_unopened_files(self) -> None: with tempfile.TemporaryDirectory() as dirpath: file1 = os.path.join(dirpath, 'file1.txt') file2 = os.path.join(dirpath, 'file2.txt') @@ -103,7 +120,7 @@ def test_changes_for_unopened_files(self) -> Generator: } params: ApplyWorkspaceEditParams = {'edit': workspace_edit} expected_result: ApplyWorkspaceEditResult = {'applied': True} - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) # Changes should have been applied window = self.view.window() for file, expected_text in zip([file1, file2], ['hello there', 'general kenobi']): @@ -112,7 +129,7 @@ def test_changes_for_unopened_files(self) -> Generator: view.set_scratch(True) view.close() - def test_fails_on_wrong_uri(self) -> Generator: + async def test_fails_on_wrong_uri(self) -> None: uri = 'file:///C:/wrong/uri.txt' workspace_edit: WorkspaceEdit = { 'documentChanges': [ @@ -133,9 +150,9 @@ def test_fails_on_wrong_uri(self) -> Generator: 'failureReason': f'Failed to open URI {uri}', 'failedChange': 0 } - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) - def test_fails_on_wrong_document_version(self) -> Generator: + async def test_fails_on_wrong_document_version(self) -> None: change_count = self.view.change_count() uri = filename_to_uri(self.view.file_name()) version = change_count - 1 @@ -158,9 +175,9 @@ def test_fails_on_wrong_document_version(self) -> Generator: 'failureReason': f'Document version for URI {uri} is {change_count}, but required {version}', 'failedChange': 0 } - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) - def test_create_file(self) -> Generator: + async def test_create_file(self) -> None: window = self.view.window() with tempfile.TemporaryDirectory() as dirpath: filepath = os.path.join(dirpath, 'newfile.txt') @@ -186,14 +203,14 @@ def test_create_file(self) -> Generator: params: ApplyWorkspaceEditParams = {'edit': workspace_edit} expected_result: ApplyWorkspaceEditResult = {'applied': True} self.assertFalse(os.path.isfile(filepath)) - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) # The file should have been created self.assertTrue(os.path.isfile(filepath)) # The TextDocumentEdit (second item in `documentChanges`) should have been applied content = entire_content(window.open_file(filepath)) self.assertEqual(content, new_text) - def test_fails_create_file_exists(self) -> Generator: + async def test_fails_create_file_exists(self) -> None: with tempfile.TemporaryDirectory() as dirpath: filepath = os.path.join(dirpath, 'newfile.txt') old_text = 'hello\nthere\n' @@ -223,12 +240,12 @@ def test_fails_create_file_exists(self) -> Generator: 'failureReason': f'CreateFile failed because a file already exists at target {uri}', 'failedChange': 0 } - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) # The file should still have its original content content = Path(filepath).read_text(encoding='utf-8') self.assertEqual(content, old_text) - def test_create_file_exists_ignore(self) -> Generator: + async def test_create_file_exists_ignore(self) -> None: window = self.view.window() with tempfile.TemporaryDirectory() as dirpath: filepath = os.path.join(dirpath, 'newfile.txt') @@ -258,12 +275,13 @@ def test_create_file_exists_ignore(self) -> Generator: } params: ApplyWorkspaceEditParams = {'edit': workspace_edit} expected_result: ApplyWorkspaceEditResult = {'applied': True} - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) # The TextDocumentEdit (second item in `documentChanges`) should have been applied content = entire_content(window.open_file(filepath)) self.assertEqual(content, new_text + old_text) - def test_create_file_exists_overwrite(self) -> Generator: + @unittest.skipIf(sys.platform == 'darwin' and CI, 'Moving files to the Recycle Bin times out on macOS CI') + async def test_create_file_exists_overwrite(self) -> None: window = self.view.window() with tempfile.TemporaryDirectory() as dirpath: filepath = os.path.join(dirpath, 'newfile.txt') @@ -294,12 +312,12 @@ def test_create_file_exists_overwrite(self) -> Generator: } params: ApplyWorkspaceEditParams = {'edit': workspace_edit} expected_result: ApplyWorkspaceEditResult = {'applied': True} - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) # The file content should only be the new text because the file was overwritten content = entire_content(window.open_file(filepath)) self.assertEqual(content, new_text) - def test_rename_file(self) -> Generator: + async def test_rename_file(self) -> None: window = self.view.window() with tempfile.TemporaryDirectory() as dirpath: old_path = os.path.join(dirpath, 'old_file.txt') @@ -329,7 +347,7 @@ def test_rename_file(self) -> Generator: } params: ApplyWorkspaceEditParams = {'edit': workspace_edit} expected_result: ApplyWorkspaceEditResult = {'applied': True} - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) # The file should have been renamed self.assertFalse(os.path.isfile(old_path)) self.assertTrue(os.path.isfile(new_path)) @@ -337,7 +355,7 @@ def test_rename_file(self) -> Generator: content = entire_content(window.open_file(new_path)) self.assertEqual(content, new_text + old_text) - def test_rename_file_exists(self) -> Generator: + async def test_rename_file_exists(self) -> None: with tempfile.TemporaryDirectory() as dirpath: old_path = os.path.join(dirpath, 'old_file.txt') new_path = os.path.join(dirpath, 'new_file.txt') @@ -372,14 +390,14 @@ def test_rename_file_exists(self) -> Generator: 'failureReason': f'RenameFile failed because target {new_uri} already exists', 'failedChange': 0 } - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) # The old file should *not* have been deleted (rename operation failed) self.assertTrue(os.path.isfile(old_path)) # The target file should still have its original content content = Path(new_path).read_text(encoding='utf-8') self.assertEqual(content, old_text2) - def test_rename_file_exists_ignore(self) -> Generator: + async def test_rename_file_exists_ignore(self) -> None: window = self.view.window() with tempfile.TemporaryDirectory() as dirpath: old_path = os.path.join(dirpath, 'old_file.txt') @@ -414,14 +432,15 @@ def test_rename_file_exists_ignore(self) -> Generator: } params: ApplyWorkspaceEditParams = {'edit': workspace_edit} expected_result: ApplyWorkspaceEditResult = {'applied': True} - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) # The old file should *not* have been deleted (rename operation ignored) self.assertTrue(os.path.isfile(old_path)) # The TextDocumentEdit (second item in `documentChanges`) should have been applied to the target file content = entire_content(window.open_file(new_path)) self.assertEqual(content, new_text + old_text2) - def test_rename_file_exists_overwrite(self) -> Generator: + @unittest.skipIf(sys.platform == 'darwin' and CI, 'Moving files to the Recycle Bin times out on macOS CI') + async def test_rename_file_exists_overwrite(self) -> None: window = self.view.window() with tempfile.TemporaryDirectory() as dirpath: old_path = os.path.join(dirpath, 'old_file.txt') @@ -457,14 +476,15 @@ def test_rename_file_exists_overwrite(self) -> Generator: } params: ApplyWorkspaceEditParams = {'edit': workspace_edit} expected_result: ApplyWorkspaceEditResult = {'applied': True} - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) # The old file should have been deleted (rename operation succeeded) self.assertFalse(os.path.isfile(old_path)) # The TextDocumentEdit (second item in `documentChanges`) should have been applied to the renamed file content = entire_content(window.open_file(new_path)) self.assertEqual(content, new_text + old_text1) - def test_delete_file(self) -> Generator: + @unittest.skipIf(sys.platform == 'darwin' and CI, 'Moving files to the Recycle Bin times out on macOS CI') + async def test_delete_file(self) -> None: with tempfile.TemporaryDirectory() as dirpath: filepath = os.path.join(dirpath, 'newfile.txt') Path(filepath).write_text('hello\nworld\n', encoding='utf-8') @@ -480,6 +500,6 @@ def test_delete_file(self) -> Generator: params: ApplyWorkspaceEditParams = {'edit': workspace_edit} expected_result: ApplyWorkspaceEditResult = {'applied': True} self.assertTrue(os.path.isfile(filepath)) - yield from verify(self, 'workspace/applyEdit', params, expected_result) + await verify(self, 'workspace/applyEdit', params, expected_result) # The file should have been deleted self.assertFalse(os.path.isfile(filepath))