diff --git a/Context.sublime-menu b/Context.sublime-menu index 08cb70ee5..5defb140f 100644 --- a/Context.sublime-menu +++ b/Context.sublime-menu @@ -31,6 +31,11 @@ "command": "lsp_code_actions", "caption": "Code Action…" }, + { + "command": "lsp_code_actions", + "args": {"only_kinds": ["refactor"]}, + "caption": "Refactor…" + }, { "command": "lsp_code_actions", "args": {"only_kinds": ["source"]}, diff --git a/Default.sublime-commands b/Default.sublime-commands index bd9c22766..92cfa689b 100644 --- a/Default.sublime-commands +++ b/Default.sublime-commands @@ -84,7 +84,9 @@ { "caption": "LSP: Goto Diagnostic", "command": "lsp_goto_diagnostic", - "args": {"uri": "$view_uri"} + "args": { + "uri": "$view_uri" + } }, { "caption": "LSP: Goto Diagnostic in Project", @@ -107,11 +109,20 @@ "command": "lsp_symbol_rename" }, { - "caption": "LSP: Run Code Action", + "caption": "LSP: Code Action…", "command": "lsp_code_actions" }, { - "caption": "LSP: Run Source Action", + "caption": "LSP: Refactor…", + "command": "lsp_code_actions", + "args": { + "only_kinds": [ + "refactor" + ] + }, + }, + { + "caption": "LSP: Source Action…", "command": "lsp_code_actions", "args": { "only_kinds": [ diff --git a/docs/src/features.md b/docs/src/features.md index 998ad3409..c70630831 100644 --- a/docs/src/features.md +++ b/docs/src/features.md @@ -136,7 +136,7 @@ Code Actions are an umbrella term for "Quick Fixes" and "Refactorings". They are Formatting is different from Code Actions, because Formatting is supposed to _not_ mutate the abstract syntax tree of the file, only move around white space. Any Code Action will mutate the abstract syntax tree. -This package presents Code Actions as a bluish clickable annotation positioned to the right of the viewport. Alternatively, they can be presented as a light bulb in the Gutter Area. +This package presents "Quick Fix" Code Actions as a bluish clickable annotation positioned to the right of the viewport. Alternatively, they can be presented as a light bulb in the Gutter Area. Sublime Text has no concept of Code Actions. diff --git a/docs/src/keyboard_shortcuts.md b/docs/src/keyboard_shortcuts.md index dbad8b40f..4e16185f5 100644 --- a/docs/src/keyboard_shortcuts.md +++ b/docs/src/keyboard_shortcuts.md @@ -26,6 +26,7 @@ Refer to the [Customization section](customization.md#keyboard-shortcuts-key-bin | Restart Server | unbound | `lsp_restart_server` | Run Code Action | unbound | `lsp_code_actions` | Run Code Lens | unbound | `lsp_code_lens` +| Run Refactor Action | unbound | `lsp_code_actions` (with args: `{"only_kinds": ["refactor"]}`) | Run Source Action | unbound | `lsp_code_actions` (with args: `{"only_kinds": ["source"]}`) | Signature Help | ctrl alt space | `lsp_signature_help_show` | Toggle Diagnostics Panel | ctrl alt m | `lsp_show_diagnostics_panel` diff --git a/plugin/code_actions.py b/plugin/code_actions.py index 62d3a4901..e1946fdcd 100644 --- a/plugin/code_actions.py +++ b/plugin/code_actions.py @@ -7,227 +7,198 @@ from .core.protocol import Request from .core.registry import LspTextCommand from .core.registry import windows +from .core.sessions import AbstractViewListener +from .core.sessions import Session from .core.sessions import SessionBufferProtocol from .core.settings import userprefs -from .core.typing import Any, List, Dict, Callable, Optional, Tuple, Union, cast +from .core.typing import Any, List, Dict, Callable, Optional, Tuple, TypeGuard, Union, cast from .core.views import entire_content_region from .core.views import first_selection_region from .core.views import format_code_actions_for_quick_panel from .core.views import text_document_code_action_params from .save_command import LspSaveCommand, SaveTask +from functools import partial import sublime +ConfigName = str CodeActionOrCommand = Union[CodeAction, Command] -CodeActionsResponse = Optional[List[CodeActionOrCommand]] -CodeActionsByConfigName = Dict[str, List[CodeActionOrCommand]] +CodeActionsByConfigName = Tuple[ConfigName, List[CodeActionOrCommand]] -class CodeActionsCollector: - """ - Collects code action responses from multiple sessions. Calls back the "on_complete_handler" with - results when all responses are received. - - Usage example: - - with CodeActionsCollector() as collector: - actions_manager.request_with_diagnostics(collector.create_collector('test_config')) - actions_manager.request_with_diagnostics(collector.create_collector('another_config')) - - The "create_collector()" must only be called within the "with" context. Once the context is - exited, the "on_complete_handler" will be called once all the created collectors receive the - response (are called). - """ - - def __init__(self, on_complete_handler: Callable[[CodeActionsByConfigName], None]): - self._on_complete_handler = on_complete_handler - self._commands_by_config = {} # type: CodeActionsByConfigName - self._request_count = 0 - self._response_count = 0 - self._all_requested = False - - def __enter__(self) -> 'CodeActionsCollector': - return self - - def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: - self._all_requested = True - self._notify_if_all_finished() - - def create_collector(self, config_name: str) -> Callable[[CodeActionsResponse], None]: - self._request_count += 1 - return lambda actions: self._collect_response(config_name, actions) - - def _collect_response(self, config_name: str, actions: CodeActionsResponse) -> None: - self._response_count += 1 - self._commands_by_config[config_name] = self._get_enabled_actions(actions or []) - self._notify_if_all_finished() - - def _get_enabled_actions(self, actions: List[CodeActionOrCommand]) -> List[CodeActionOrCommand]: - return [action for action in actions if not action.get('disabled')] - - def _notify_if_all_finished(self) -> None: - if self._all_requested and self._request_count == self._response_count: - # Call back on Sublime's async thread - sublime.set_timeout_async(lambda: self._on_complete_handler(self._commands_by_config)) - - def get_actions(self) -> CodeActionsByConfigName: - return self._commands_by_config +def is_command(action: CodeActionOrCommand) -> TypeGuard[Command]: + return isinstance(action.get('command'), str) class CodeActionsManager: """Manager for per-location caching of code action responses.""" def __init__(self) -> None: - self._response_cache = None # type: Optional[Tuple[str, CodeActionsCollector]] + self._response_cache = None # type: Optional[Tuple[str, Promise[List[CodeActionsByConfigName]]]] def request_for_region_async( self, view: sublime.View, region: sublime.Region, session_buffer_diagnostics: List[Tuple[SessionBufferProtocol, List[Diagnostic]]], - actions_handler: Callable[[CodeActionsByConfigName], None], only_kinds: Optional[List[CodeActionKind]] = None, manual: bool = False, - ) -> None: + ) -> Promise[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. """ - self._request_async( - view, - region, - session_buffer_diagnostics, - only_with_diagnostics=False, - actions_handler=actions_handler, - on_save_actions=None, - only_kinds=only_kinds, - manual=manual) - - def request_on_save( - self, - view: sublime.View, - actions_handler: Callable[[CodeActionsByConfigName], None], - on_save_actions: Dict[str, bool] - ) -> None: - """ - Requests code actions on save. - """ - listener = windows.listener_for_view(view) - if not listener: - return - region = entire_content_region(view) - session_buffer_diagnostics, _ = listener.diagnostics_intersecting_region_async(region) - self._request_async( - view, - region, - session_buffer_diagnostics, - only_with_diagnostics=False, - actions_handler=actions_handler, - on_save_actions=on_save_actions, - only_kinds=None, - manual=False) - - def _request_async( - self, - view: sublime.View, - region: sublime.Region, - session_buffer_diagnostics: List[Tuple[SessionBufferProtocol, List[Diagnostic]]], - only_with_diagnostics: bool, - actions_handler: Callable[[CodeActionsByConfigName], None], - on_save_actions: Optional[Dict[str, bool]] = None, - only_kinds: Optional[List[CodeActionKind]] = None, - manual: bool = False, - ) -> None: listener = windows.listener_for_view(view) if not listener: - return + return Promise.resolve([]) location_cache_key = None - use_cache = on_save_actions is None and not manual + use_cache = not manual if use_cache: - location_cache_key = "{}#{}:{}:{}".format( - view.buffer_id(), view.change_count(), region, only_with_diagnostics) + location_cache_key = "{}#{}:{}".format(view.buffer_id(), view.change_count(), region) if self._response_cache: - cache_key, cache_collector = self._response_cache + cache_key, task = self._response_cache if location_cache_key == cache_key: - sublime.set_timeout(lambda: actions_handler(cache_collector.get_actions())) - return + return task else: self._response_cache = None - collector = CodeActionsCollector(actions_handler) - with collector: - for session in listener.sessions_async('codeActionProvider'): - diagnostics = [] # type: List[Diagnostic] - for sb, diags in session_buffer_diagnostics: - if sb.session == session: - diagnostics = diags - break - if on_save_actions is not None: - supported_kinds = session.get_capability('codeActionProvider.codeActionKinds') # type: Optional[List[CodeActionKind]] # noqa: E501 - matching_kinds = get_matching_kinds(on_save_actions, supported_kinds or []) - if matching_kinds: - params = text_document_code_action_params(view, region, diagnostics, matching_kinds, manual) - request = Request.codeAction(params, view) - session.send_request_async( - request, *filtering_collector(session.config.name, matching_kinds, collector)) - else: - if only_with_diagnostics and not diagnostics: - continue - params = text_document_code_action_params(view, region, diagnostics, only_kinds, manual) - request = Request.codeAction(params, view) - session.send_request_async(request, collector.create_collector(session.config.name)) - if location_cache_key: - self._response_cache = (location_cache_key, collector) + def request_factory(session: Session) -> Optional[Request]: + diagnostics = [] # type: List[Diagnostic] + for sb, diags in session_buffer_diagnostics: + if sb.session == session: + diagnostics = diags + break + params = text_document_code_action_params(view, region, diagnostics, only_kinds, manual) + return Request.codeAction(params, view) + + def response_filter(session: Session, actions: List[CodeActionOrCommand]) -> List[CodeActionOrCommand]: + # Filter out non "quickfix" code actions unless "only_kinds" is provided. + if only_kinds: + return [a for a in actions if not is_command(a) and kinds_include_kind(only_kinds, a.get('kind'))] + if manual: + return actions + # On implicit (selection change) request, only return commands and quick fix kinds. + return [ + a for a in actions + if is_command(a) or not a.get('kind') or kinds_include_kind([CodeActionKind.QuickFix], a.get('kind')) + ] + + task = self._collect_code_actions_async(listener, request_factory, response_filter) + if location_cache_key: + self._response_cache = (location_cache_key, task) + return task -def filtering_collector( - config_name: str, - kinds: List[CodeActionKind], - actions_collector: CodeActionsCollector -) -> Tuple[Callable[[CodeActionsResponse], None], Callable[[Any], None]]: - """ - Filters actions returned from the session so that only matching kinds are collected. + def request_on_save_async( + self, view: sublime.View, on_save_actions: Dict[str, bool] + ) -> Promise[List[CodeActionsByConfigName]]: + listener = windows.listener_for_view(view) + if not listener: + return Promise.resolve([]) + region = entire_content_region(view) + session_buffer_diagnostics, _ = listener.diagnostics_intersecting_region_async(region) - Since older servers don't support the "context.only" property, these will return all - actions that need to be filtered. - """ + def request_factory(session: Session) -> Optional[Request]: + session_kinds = get_session_kinds(session) + matching_kinds = get_matching_on_save_kinds(on_save_actions, session_kinds) + if not matching_kinds: + return None + diagnostics = [] # type: List[Diagnostic] + for sb, diags in session_buffer_diagnostics: + if sb.session == session: + diagnostics = diags + break + params = text_document_code_action_params(view, region, diagnostics, matching_kinds, manual=False) + return Request.codeAction(params, view) + + def response_filter(session: Session, actions: List[CodeActionOrCommand]) -> List[CodeActionOrCommand]: + # 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(session) + matching_kinds = get_matching_on_save_kinds(on_save_actions, session_kinds) + return [a for a in actions if a.get('kind') in matching_kinds] + + return self._collect_code_actions_async(listener, request_factory, response_filter) + + def _collect_code_actions_async( + self, + listener: AbstractViewListener, + request_factory: Callable[[Session], Optional[Request]], + response_filter: Optional[Callable[[Session, List[CodeActionOrCommand]], List[CodeActionOrCommand]]] = None, + ) -> Promise[List[CodeActionsByConfigName]]: + + def on_response( + session: Session, response: Union[Error, Optional[List[CodeActionOrCommand]]] + ) -> CodeActionsByConfigName: + if isinstance(response, Error): + actions = [] + else: + actions = [action for action in (response or []) if not action.get('disabled')] + if actions and response_filter: + actions = response_filter(session, actions) + return (session.config.name, actions) + + tasks = [] # type: List[Promise[CodeActionsByConfigName]] + for session in listener.sessions_async('codeActionProvider'): + request = request_factory(session) + if request: + response_handler = partial(on_response, session) + task = session.send_request_task(request) # type: Promise[Optional[List[CodeActionOrCommand]]] + tasks.append(task.then(response_handler)) + # Return only results for non-empty lists. + return Promise.all(tasks).then(lambda sessions: list(filter(lambda session: len(session[1]), sessions))) - def actions_filter(actions: CodeActionsResponse) -> List[CodeActionOrCommand]: - return [a for a in (actions or []) if a.get('kind') in kinds] - collector = actions_collector.create_collector(config_name) - return ( - lambda actions: collector(actions_filter(actions)), - lambda error: collector([]) - ) +actions_manager = CodeActionsManager() -actions_manager = CodeActionsManager() +def get_session_kinds(session: Session) -> List[CodeActionKind]: + session_kinds = session.get_capability('codeActionProvider.codeActionKinds') # type: Optional[List[CodeActionKind]] + return session_kinds or [] -def get_matching_kinds(user_actions: Dict[str, bool], session_actions: List[CodeActionKind]) -> List[CodeActionKind]: +def get_matching_on_save_kinds( + user_actions: Dict[str, bool], session_kinds: List[CodeActionKind] +) -> List[CodeActionKind]: """ - Filters user-enabled or disabled actions so that only ones matching the session actions - are returned. Returned actions are those that are enabled and are not overridden by more - specific, disabled actions. + Filters user-enabled or disabled actions so that only ones matching the session kinds + are returned. Returned kinds are those that are enabled and are not overridden by more + specific, disabled kinds. - Filtering only returns actions that exactly match the ones supported by given session. + Filtering only returns kinds that exactly match the ones supported by given session. If user has enabled a generic action that matches more specific session action (for example user's a.b matching session's a.b.c), then the more specific (a.b.c) must be - returned as servers must receive only actions that they advertise support for. + returned as servers must receive only kinds that they advertise support for. """ matching_kinds = [] - for session_action in session_actions: + for session_kind in session_kinds: enabled = False - action_parts = cast(str, session_action).split('.') + action_parts = session_kind.split('.') for i in range(len(action_parts)): current_part = '.'.join(action_parts[0:i + 1]) user_value = user_actions.get(current_part, None) if isinstance(user_value, bool): enabled = user_value if enabled: - matching_kinds.append(session_action) + matching_kinds.append(session_kind) return matching_kinds +def kinds_include_kind(kinds: List[CodeActionKind], kind: Optional[CodeActionKind]) -> bool: + """ + The "kinds" include "kind" if "kind" matches one of the "kinds" exactly or one of the "kinds" is a prefix + of the whole "kind" (where prefix must be followed by a dot). + """ + if not kind: + return False + for kinds_item in kinds: + if kinds_item == kind: + return True + kinds_item_len = len(kinds_item) + if len(kind) > kinds_item_len and kind.startswith(kinds_item) and kind[kinds_item_len] == '.': + return True + return False + + class CodeActionOnSaveTask(SaveTask): """ The main task that requests code actions from sessions and runs them. @@ -241,7 +212,7 @@ def is_applicable(cls, view: sublime.View) -> bool: @classmethod def _get_code_actions_on_save(cls, view: sublime.View) -> Dict[str, bool]: - view_code_actions = view.settings().get('lsp_code_actions_on_save') or {} + view_code_actions = cast(Dict[str, bool], view.settings().get('lsp_code_actions_on_save') or {}) code_actions = userprefs().lsp_code_actions_on_save.copy() code_actions.update(view_code_actions) allowed_code_actions = dict() @@ -257,20 +228,17 @@ def run_async(self) -> None: def _request_code_actions_async(self) -> None: self._purge_changes_async() on_save_actions = self._get_code_actions_on_save(self._task_runner.view) - actions_manager.request_on_save(self._task_runner.view, self._handle_response_async, on_save_actions) + actions_manager.request_on_save_async(self._task_runner.view, on_save_actions).then(self._handle_response_async) - def _handle_response_async(self, responses: CodeActionsByConfigName) -> None: + def _handle_response_async(self, responses: List[CodeActionsByConfigName]) -> None: if self._cancelled: return document_version = self._task_runner.view.change_count() tasks = [] # type: List[Promise] - for config_name, code_actions in responses.items(): - if code_actions: - for session in self._task_runner.sessions('codeActionProvider'): - if session.config.name == config_name: - for code_action in code_actions: - tasks.append(session.run_code_action_async(code_action, progress=False)) - break + for config_name, code_actions in responses: + session = self._task_runner.session_by_name(config_name, 'codeActionProvider') + if session: + tasks.extend([session.run_code_action_async(action, progress=False) for action in code_actions]) Promise.all(tasks).then(lambda _: self._on_code_actions_completed(document_version)) def _on_code_actions_completed(self, previous_document_version: int) -> None: @@ -293,65 +261,65 @@ def run( edit: sublime.Edit, event: Optional[dict] = None, only_kinds: Optional[List[CodeActionKind]] = None, - commands_by_config: Optional[CodeActionsByConfigName] = None + code_actions_by_config: Optional[List[CodeActionsByConfigName]] = None ) -> None: - self.commands = [] # type: List[Tuple[str, CodeActionOrCommand]] - self.commands_by_config = {} # type: CodeActionsByConfigName - if commands_by_config: - self.handle_responses_async(commands_by_config, run_first=True) - else: - view = self.view - region = first_selection_region(view) - if region is None: - return - listener = windows.listener_for_view(view) - if not listener: - return - session_buffer_diagnostics, covering = listener.diagnostics_intersecting_async(region) - actions_manager.request_for_region_async( - view, covering, session_buffer_diagnostics, self.handle_responses_async, only_kinds, manual=True) - - def handle_responses_async(self, responses: CodeActionsByConfigName, run_first: bool = False) -> None: - self.commands_by_config = responses - self.commands = self.combine_commands() - if len(self.commands) == 1 and run_first: - self.handle_select(0) - else: - self.show_code_actions() - - def combine_commands(self) -> 'List[Tuple[str, CodeActionOrCommand]]': - results = [] - for config, commands in self.commands_by_config.items(): - for command in commands: - results.append((config, command)) - return results + if code_actions_by_config: + self._handle_code_actions(code_actions_by_config, run_first=True) + return + self._run_async(only_kinds) - def show_code_actions(self) -> None: - if len(self.commands) > 0: + def _run_async(self, only_kinds: Optional[List[CodeActionKind]] = None) -> None: + view = self.view + region = first_selection_region(view) + if region is None: + return + listener = windows.listener_for_view(view) + if not listener: + return + session_buffer_diagnostics, covering = listener.diagnostics_intersecting_async(region) + actions_manager \ + .request_for_region_async(view, covering, session_buffer_diagnostics, only_kinds, manual=True) \ + .then(lambda 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. + actions = [] # type: List[Tuple[ConfigName, CodeActionOrCommand]] + for config_name, session_actions in response: + actions.extend([(config_name, action) for action in session_actions]) + if actions: + if len(actions) == 1 and run_first: + self._handle_select(0, actions) + else: + self._show_code_actions(actions) + else: window = self.view.window() if window: - items, selected_index = format_code_actions_for_quick_panel([command[1] for command in self.commands]) - window.show_quick_panel( - items, - self.handle_select, - selected_index=selected_index, - placeholder="Code action") - else: - self.view.show_popup('No actions available', sublime.HIDE_ON_MOUSE_MOVE_AWAY) + window.status_message("No code actions available") - def handle_select(self, index: int) -> None: - if index > -1: + def _show_code_actions(self, actions: List[Tuple[ConfigName, CodeActionOrCommand]]) -> None: + window = self.view.window() + if not window: + return + items, selected_index = format_code_actions_for_quick_panel(actions) + window.show_quick_panel( + items, + lambda i: self._handle_select(i, actions), + selected_index=selected_index, + placeholder="Code action") + + def _handle_select(self, index: int, actions: List[Tuple[ConfigName, CodeActionOrCommand]]) -> None: + if index == -1: + return - def run_async() -> None: - selected = self.commands[index] - session = self.session_by_name(selected[0]) - if session: - name = session.config.name - session.run_code_action_async(selected[1], progress=True).then( - lambda resp: self.handle_response_async(name, resp)) + def run_async() -> None: + config_name, action = actions[index] + session = self.session_by_name(config_name) + if session: + session.run_code_action_async(action, progress=True) \ + .then(lambda response: self._handle_response_async(config_name, response)) - sublime.set_timeout_async(run_async) + sublime.set_timeout_async(run_async) - def handle_response_async(self, session_name: str, response: Any) -> None: + def _handle_response_async(self, session_name: str, response: Any) -> None: if isinstance(response, Error): sublime.error_message("{}: {}".format(session_name, str(response))) diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py index 49daa701e..8220070c6 100644 --- a/plugin/core/protocol.py +++ b/plugin/core/protocol.py @@ -1,4 +1,4 @@ -from .typing import Enum, IntEnum, IntFlag +from .typing import Enum, IntEnum, IntFlag, StrEnum from .typing import Any, Dict, Iterable, List, Literal, Mapping, NotRequired, Optional, TypedDict, Union import sublime @@ -327,7 +327,7 @@ class DocumentHighlightKind(IntEnum): """ Write-access of a symbol, like writing to a variable. """ -class CodeActionKind(Enum): +class CodeActionKind(StrEnum): """ A set of predefined code action kinds """ Empty = '' """ Empty kind. """ diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 4efa757c0..92355d0eb 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -336,6 +336,7 @@ def get_initialize_params(variables: Dict[str, str], workspace_folders: List[Wor CodeActionKind.RefactorExtract, CodeActionKind.RefactorInline, CodeActionKind.RefactorRewrite, + CodeActionKind.SourceFixAll, CodeActionKind.SourceOrganizeImports, ] } diff --git a/plugin/core/typing.py b/plugin/core/typing.py index fd3cd8398..bcb251a85 100644 --- a/plugin/core/typing.py +++ b/plugin/core/typing.py @@ -2,7 +2,7 @@ if sys.version_info >= (3, 11, 0): - from enum import Enum, IntEnum, IntFlag + from enum import Enum, IntEnum, IntFlag, StrEnum from typing import Any from typing import Callable from typing import cast @@ -25,6 +25,7 @@ from typing import Tuple from typing import Type from typing import TypedDict + from typing import TypeGuard from typing import TypeVar from typing import Union @@ -57,6 +58,9 @@ class TypedDict(Type, dict): # type: ignore def __init__(*args, **kwargs) -> None: # type: ignore pass + class TypeGuard(Type): # type: ignore + pass + class Enum(Type): # type: ignore pass @@ -66,6 +70,9 @@ class IntEnum(Type): # type: ignore class IntFlag(Type): # type: ignore pass + class StrEnum(Type): # type: ignore + pass + class Any(Type): # type: ignore pass diff --git a/plugin/core/views.py b/plugin/core/views.py index 53e0cb26e..ebf87ee4d 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -1049,15 +1049,15 @@ def format_completion( def format_code_actions_for_quick_panel( - code_actions: List[Union[CodeAction, Command]] + session_actions: Iterable[Tuple[str, Union[CodeAction, Command]]] ) -> Tuple[List[sublime.QuickPanelItem], int]: items = [] # type: List[sublime.QuickPanelItem] selected_index = -1 - for idx, code_action in enumerate(code_actions): + for idx, (config_name, code_action) in enumerate(session_actions): lsp_kind = code_action.get("kind", "") first_kind_component = cast(CodeActionKind, str(lsp_kind).split(".")[0]) kind = CODE_ACTION_KINDS.get(first_kind_component, sublime.KIND_AMBIGUOUS) - items.append(sublime.QuickPanelItem(code_action["title"], kind=kind)) + items.append(sublime.QuickPanelItem(code_action["title"], annotation=config_name, kind=kind)) if code_action.get('isPreferred', False): selected_index = idx return items, selected_index diff --git a/plugin/documents.py b/plugin/documents.py index 3a5219107..020ad3935 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -176,7 +176,7 @@ def _setup(self) -> None: self._stored_region = sublime.Region(-1, -1) self._sighelp = None # type: Optional[SigHelp] self._lightbulb_line = None # type: Optional[int] - self._actions_by_config = {} # type: Dict[str, List[CodeActionOrCommand]] + self._actions_by_config = [] # type: List[CodeActionsByConfigName] self._registered = False def _cleanup(self) -> None: @@ -284,7 +284,7 @@ def diagnostics_touching_point_async( def on_diagnostics_updated_async(self) -> None: self._clear_code_actions_annotation() if userprefs().show_code_actions: - self._do_code_actions() + self._do_code_actions_async() self._update_diagnostic_in_status_bar_async() window = self.view.window() is_active_view = window and window.active_view() == self.view @@ -369,7 +369,7 @@ def on_selection_modified_async(self) -> None: after_ms=self.highlights_debounce_time) self._clear_code_actions_annotation() if userprefs().show_code_actions: - self._when_selection_remains_stable_async(self._do_code_actions, current_region, + self._when_selection_remains_stable_async(self._do_code_actions_async, current_region, after_ms=self.code_actions_debounce_time) self._update_diagnostic_in_status_bar_async() self._resolve_visible_code_lenses_async() @@ -590,13 +590,17 @@ def _on_sighelp_navigate(self, href: str) -> None: # --- textDocument/codeAction -------------------------------------------------------------------------------------- - def _do_code_actions(self) -> None: + def _do_code_actions_async(self) -> None: diagnostics_by_config, covering = self.diagnostics_intersecting_async(self._stored_region) - actions_manager.request_for_region_async( - self.view, covering, diagnostics_by_config, self._on_code_actions, manual=False) - - def _on_code_actions(self, responses: CodeActionsByConfigName) -> None: - action_count = sum(map(len, responses.values())) + actions_manager \ + .request_for_region_async(self.view, covering, diagnostics_by_config, manual=False) \ + .then(self._on_code_actions) + + def _on_code_actions(self, responses: List[CodeActionsByConfigName]) -> None: + # flatten list + action_lists = [actions for _, actions in responses if len(actions)] + all_actions = [action for actions in action_lists for action in actions] + action_count = len(all_actions) if action_count == 0: return regions = [sublime.Region(self._stored_region.b, self._stored_region.a)] @@ -614,9 +618,9 @@ def _on_code_actions(self, responses: CodeActionsByConfigName) -> None: if action_count > 1: title = '{} code actions'.format(action_count) else: - title = next(itertools.chain.from_iterable(responses.values()))['title'] + title = all_actions[0]['title'] title = "
".join(textwrap.wrap(title, width=30)) - code_actions_link = make_command_link('lsp_code_actions', title, {"commands_by_config": responses}) + code_actions_link = make_command_link('lsp_code_actions', title, {"code_actions_by_config": responses}) annotations = ["
{}
".format(code_actions_link)] annotation_color = self.view.style_for_scope("region.bluish markup.accent.codeaction.lsp")["foreground"] self.view.add_regions(SessionView.CODE_ACTIONS_KEY, regions, scope, icon, flags, annotations, annotation_color) @@ -628,26 +632,30 @@ def _clear_code_actions_annotation(self) -> None: def _on_navigate(self, href: str, point: int) -> None: if href.startswith('code-actions:'): _, config_name = href.split(":") - actions = self._actions_by_config[config_name] + actions = next(actions for name, actions in self._actions_by_config if name == config_name) if len(actions) > 1: window = self.view.window() if window: - items, selected_index = format_code_actions_for_quick_panel(actions) + items, selected_index = format_code_actions_for_quick_panel( + map(lambda action: (config_name, action), actions)) window.show_quick_panel( items, - lambda i: self.handle_code_action_select(config_name, i), + lambda i: self.handle_code_action_select(config_name, actions, i), selected_index=selected_index, placeholder="Code actions") else: - self.handle_code_action_select(config_name, 0) - - def handle_code_action_select(self, config_name: str, index: int) -> None: - if index > -1: - def run_async() -> None: - session = self.session_by_name(config_name) - if session: - session.run_code_action_async(self._actions_by_config[config_name][index], progress=True) - sublime.set_timeout_async(run_async) + self.handle_code_action_select(config_name, actions, 0) + + def handle_code_action_select(self, config_name: str, actions: List[CodeActionOrCommand], index: int) -> None: + if index == -1: + return + + def run_async() -> None: + session = self.session_by_name(config_name) + if session: + session.run_code_action_async(actions[index], progress=True) + + sublime.set_timeout_async(run_async) # --- textDocument/codeLens ---------------------------------------------------------------------------------------- diff --git a/plugin/hover.py b/plugin/hover.py index 04fbb60cd..97d56784d 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -1,5 +1,6 @@ from .code_actions import actions_manager from .code_actions import CodeActionOrCommand +from .code_actions import CodeActionsByConfigName from .core.open import open_file_uri from .core.open import open_in_browser from .core.promise import Promise @@ -79,18 +80,20 @@ def link(self, point: int, view: sublime.View) -> str: ] -def code_actions_content(actions_by_config: Dict[str, List[CodeActionOrCommand]]) -> str: +def code_actions_content(actions_by_config: List[CodeActionsByConfigName]) -> str: formatted = [] - for config_name, actions in actions_by_config.items(): + for config_name, actions in actions_by_config: action_count = len(actions) - if action_count > 0: - href = "{}:{}".format('code-actions', config_name) - if action_count > 1: - text = "choose code action ({} available)".format(action_count) - else: - text = actions[0].get('title', 'code action') - formatted.append('
[{}] Code action: {}
'.format( - config_name, make_link(href, text))) + if action_count == 0: + continue + if action_count > 1: + text = "choose ({} available)".format(action_count) + else: + text = actions[0].get('title', 'code action') + href = "{}:{}".format('code-actions', config_name) + link = make_link(href, text) + formatted.append( + '
Quick Fix: {} {}
'.format(link, config_name)) return "".join(formatted) @@ -122,7 +125,7 @@ def run( self._base_dir = wm.get_project_path(self.view.file_name() or "") self._hover_responses = [] # type: List[Tuple[Hover, Optional[MarkdownLangMap]]] self._document_link = ('', False, None) # type: Tuple[str, bool, Optional[sublime.Region]] - self._actions_by_config = {} # type: Dict[str, List[CodeActionOrCommand]] + self._actions_by_config = [] # type: List[CodeActionsByConfigName] self._diagnostics_by_config = [] # type: Sequence[Tuple[SessionBufferProtocol, Sequence[Diagnostic]]] # TODO: For code actions it makes more sense to use the whole selection under mouse (if available) # rather than just the hover point. @@ -140,9 +143,9 @@ def run_async() -> None: if self._diagnostics_by_config: self.show_hover(listener, hover_point, only_diagnostics) if not only_diagnostics and userprefs().show_code_actions_in_hover: - actions_manager.request_for_region_async( - self.view, covering, self._diagnostics_by_config, - functools.partial(self.handle_code_actions, listener, hover_point), manual=False) + actions_manager \ + .request_for_region_async(self.view, covering, self._diagnostics_by_config, manual=False) \ + .then(lambda results: self._handle_code_actions(listener, hover_point, results)) sublime.set_timeout_async(run_async) @@ -230,11 +233,11 @@ def _on_all_document_links_resolved( self._document_link = ('
'.join(contents) if contents else '', link_has_standard_tooltip, link_range) self.show_hover(listener, point, only_diagnostics=False) - def handle_code_actions( + def _handle_code_actions( self, listener: AbstractViewListener, point: int, - responses: Dict[str, List[CodeActionOrCommand]] + responses: List[CodeActionsByConfigName] ) -> None: self._actions_by_config = responses self.show_hover(listener, point, only_diagnostics=False) @@ -329,20 +332,21 @@ def _on_navigate(self, href: str, point: int) -> None: if window: open_file_uri(window, href) elif href.startswith('code-actions:'): - _, config_name = href.split(":") - actions = self._actions_by_config[config_name] self.view.run_command("lsp_selection_set", {"regions": [(point, point)]}) + _, config_name = href.split(":") + actions = next(actions for name, actions in self._actions_by_config if name == config_name) if len(actions) > 1: window = self.view.window() if window: - items, selected_index = format_code_actions_for_quick_panel(actions) + items, selected_index = format_code_actions_for_quick_panel( + map(lambda action: (config_name, action), actions)) window.show_quick_panel( items, - lambda i: self.handle_code_action_select(config_name, i), + lambda i: self.handle_code_action_select(config_name, actions, i), selected_index=selected_index, placeholder="Code actions") else: - self.handle_code_action_select(config_name, 0) + self.handle_code_action_select(config_name, actions, 0) elif is_location_href(href): session_name, uri, row, col_utf16 = unpack_href_location(href) session = self.session_by_name(session_name) @@ -353,12 +357,13 @@ def _on_navigate(self, href: str, point: int) -> None: else: open_in_browser(href) - def handle_code_action_select(self, config_name: str, index: int) -> None: - if index > -1: + def handle_code_action_select(self, config_name: str, actions: List[CodeActionOrCommand], index: int) -> None: + if index == -1: + return - def run_async() -> None: - session = self.session_by_name(config_name) - if session: - session.run_code_action_async(self._actions_by_config[config_name][index], progress=True) + def run_async() -> None: + session = self.session_by_name(config_name) + if session: + session.run_code_action_async(actions[index], progress=True) - sublime.set_timeout_async(run_async) + sublime.set_timeout_async(run_async) diff --git a/popups.css b/popups.css index dd562ff79..f838a57b8 100644 --- a/popups.css +++ b/popups.css @@ -22,6 +22,9 @@ border-width: 0; border-radius: 0; } +.color-muted { + color: color(var(--foreground) alpha(0.50)); +} .diagnostics { margin-bottom: 0.5rem; font-family: var(--mdpopups-font-mono); diff --git a/tests/test_code_actions.py b/tests/test_code_actions.py index 28af0f5fa..cbd1f57c5 100644 --- a/tests/test_code_actions.py +++ b/tests/test_code_actions.py @@ -1,5 +1,5 @@ from copy import deepcopy -from LSP.plugin.code_actions import get_matching_kinds +from LSP.plugin.code_actions import get_matching_on_save_kinds, kinds_include_kind from LSP.plugin.core.protocol import Point, Range from LSP.plugin.core.typing import Any, Dict, Generator, List, Tuple, Optional from LSP.plugin.core.url import filename_to_uri @@ -45,7 +45,7 @@ def create_command(command_name: str, command_args: Optional[List[Any]] = None) def create_test_code_action(view: sublime.View, version: int, edits: List[Tuple[str, Range]], - kind: str = None) -> Dict[str, Any]: + kind: Optional[str] = None) -> Dict[str, Any]: action = { "title": "Fix errors", "edit": create_code_action_edit(view, version, edits) @@ -56,7 +56,7 @@ def create_test_code_action(view: sublime.View, version: int, edits: List[Tuple[ def create_test_code_action2(command_name: str, command_args: Optional[List[Any]] = None, - kind: str = None) -> Dict[str, Any]: + kind: Optional[str] = None) -> Dict[str, Any]: action = { "title": "Fix errors", "command": create_command(command_name, command_args) @@ -232,25 +232,38 @@ def _setup_document_with_missing_semicolon(self) -> Generator: class CodeActionMatchingTestCase(unittest.TestCase): def test_does_not_match(self) -> None: - actual = get_matching_kinds({'a.x': True}, ['a.b']) + actual = get_matching_on_save_kinds({'a.x': True}, ['a.b']) expected = [] # type: List[str] self.assertEquals(actual, expected) def test_matches_exact_action(self) -> None: - actual = get_matching_kinds({'a.b': True}, ['a.b']) + actual = get_matching_on_save_kinds({'a.b': True}, ['a.b']) expected = ['a.b'] self.assertEquals(actual, expected) def test_matches_more_specific_action(self) -> None: - actual = get_matching_kinds({'a.b': True}, ['a.b.c']) + actual = get_matching_on_save_kinds({'a.b': True}, ['a.b.c']) expected = ['a.b.c'] self.assertEquals(actual, expected) def test_does_not_match_disabled_action(self) -> None: - actual = get_matching_kinds({'a.b': True, 'a.b.c': False}, ['a.b.c']) + actual = get_matching_on_save_kinds({'a.b': True, 'a.b.c': False}, ['a.b.c']) expected = [] # type: List[str] self.assertEquals(actual, expected) + def test_kind_matching(self) -> None: + # Positive + self.assertTrue(kinds_include_kind(['a'], 'a.b')) + self.assertTrue(kinds_include_kind(['a.b'], 'a.b')) + self.assertTrue(kinds_include_kind(['a.b', 'b'], 'b.c')) + # Negative + self.assertFalse(kinds_include_kind(['a'], 'b.a')) + self.assertFalse(kinds_include_kind(['a.b'], 'b')) + self.assertFalse(kinds_include_kind(['a.b'], 'a')) + self.assertFalse(kinds_include_kind(['aa'], 'a')) + self.assertFalse(kinds_include_kind(['aa.b'], 'a')) + self.assertFalse(kinds_include_kind(['aa.b'], 'b')) + class CodeActionsListenerTestCase(TextDocumentTestCase): def setUp(self) -> Generator: