diff --git a/Default.sublime-keymap b/Default.sublime-keymap index 0c17f8203..541512998 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -25,6 +25,25 @@ } ] }, + // Insert/Replace Completions + { + "command": "lsp_commit_completion_with_opposite_insert_mode", + "keys": [ + "alt+enter" + ], + "context": [ + { + "key": "auto_complete_visible", + "operator": "equal", + "operand": true + }, + { + "key": "lsp.session_with_capability", + "operator": "equal", + "operand": "completionProvider" + } + ] + }, // Save all open files with lsp_save // { // "command": "lsp_save_all", diff --git a/LSP.sublime-settings b/LSP.sublime-settings index 298957989..40fc2c275 100644 --- a/LSP.sublime-settings +++ b/LSP.sublime-settings @@ -169,6 +169,14 @@ // sorting algorithm and instead uses the sorting defined by the relevant language server. "inhibit_word_completions": true, + // The mode used for inserting completions: + // - `insert` would insert the completion text in a middle of the word + // - `replace` would replace the existing word with a new completion text + // An LSP keybinding `lsp_commit_completion_with_opposite_insert_mode` + // can be used to insert completion using the opposite mode to the one selected here. + // Note: Must be supported by the language server. + "completion_insert_mode": "insert", + // Show symbol references in Sublime's quick panel instead of the bottom panel. "show_references_in_quick_panel": false, diff --git a/boot.py b/boot.py index ef9cf6b4b..4cfb2c029 100644 --- a/boot.py +++ b/boot.py @@ -5,6 +5,7 @@ # Please keep this list sorted (Edit -> Sort Lines) from .plugin.code_actions import LspCodeActionsCommand from .plugin.code_lens import LspCodeLensCommand +from .plugin.completion import LspCommitCompletionWithOppositeInsertMode from .plugin.completion import LspResolveDocsCommand from .plugin.completion import LspSelectCompletionItemCommand from .plugin.configuration import LspDisableLanguageServerGloballyCommand diff --git a/docs/src/features.md b/docs/src/features.md index 545383ba1..a328f84c5 100644 --- a/docs/src/features.md +++ b/docs/src/features.md @@ -15,6 +15,11 @@ The LSP package enhances the auto-complete list with results provided by the lan To show the documentation popup you can click the **More** link in the bottom of the autocomplete, or you can use the default sublime keybinding F12 to trigger it. +To insert or replace a completion item using the opposite "completion_insert_mode" setting value, the following keybinding can be used alt+enter. +Note, this feature can only be used if **Replace** or **Insert** are shown at the bottom of the autocomplete popup. + +[Example GIF 3](https://user-images.githubusercontent.com/22029477/189607770-1a8018f6-1fd1-40de-b6d9-be1f657dfc0d.gif) + ## Goto Definition [Example GIF 1](https://user-images.githubusercontent.com/6579999/128551655-bfd55991-70a9-43da-a54a-f8d4cb3244c4.gif) diff --git a/docs/src/keyboard_shortcuts.md b/docs/src/keyboard_shortcuts.md index c2a78270e..753f1b7e5 100644 --- a/docs/src/keyboard_shortcuts.md +++ b/docs/src/keyboard_shortcuts.md @@ -18,17 +18,18 @@ Refer to the [Customization section](customization.md#keyboard-shortcuts-key-bin | Goto Declaration | unbound | `lsp_symbol_declaration` | Goto Definition | unbound
suggested: f12 | `lsp_symbol_definition` | Goto Implementation | unbound | `lsp_symbol_implementation` -| Goto Symbol | unbound
suggested: ctrl r | `lsp_document_symbols` | Goto Symbol in Project | unbound
suggested: ctrl shift r | `lsp_workspace_symbols` +| Goto Symbol | unbound
suggested: ctrl r | `lsp_document_symbols` | Goto Type Definition | unbound | `lsp_symbol_type_definition` +| Hover Popup | unbound | `lsp_hover` +| Insert/Replace Completions | alt enter | `lsp_commit_completion_with_opposite_insert_mode` | Next Diagnostic | F4 | - | Previous Diagnostic | shift F4 | - | Rename | unbound | `lsp_symbol_rename` | Restart Server | unbound | `lsp_restart_server` | Run Code Action | unbound | `lsp_code_actions` -| Run Source Action | unbound | `lsp_code_actions` (with args: `{"only_kinds": ["source"]}`) | Run Code Lens | unbound | `lsp_code_lens` +| Run Source Action | unbound | `lsp_code_actions` (with args: `{"only_kinds": ["source"]}`) | Signature Help | ctrl alt space | `lsp_signature_help_show` -| Hover Popup | unbound | `lsp_hover` | Toggle Diagnostics Panel | ctrl alt m | `lsp_show_diagnostics_panel` | Toggle Log Panel | unbound | `lsp_toggle_server_panel` diff --git a/plugin/completion.py b/plugin/completion.py index c4dd1e513..3d24b08f5 100644 --- a/plugin/completion.py +++ b/plugin/completion.py @@ -1,8 +1,9 @@ from .core.edit import parse_text_edit from .core.logging import debug -from .core.protocol import Request, InsertTextFormat, Range, CompletionItem +from .core.protocol import InsertReplaceEdit, TextEdit, RangeLsp, Request, InsertTextFormat, Range, CompletionItem from .core.registry import LspTextCommand -from .core.typing import List, Dict, Optional, Generator, Union +from .core.settings import userprefs +from .core.typing import List, Dict, Optional, Generator, Union, cast from .core.views import FORMAT_STRING, FORMAT_MARKUP_CONTENT from .core.views import MarkdownLangMap from .core.views import minihtml @@ -16,6 +17,17 @@ SessionName = str +def get_text_edit_range(text_edit: Union[TextEdit, InsertReplaceEdit]) -> RangeLsp: + if 'insert' in text_edit and 'replace' in text_edit: + text_edit = cast(InsertReplaceEdit, text_edit) + insert_mode = userprefs().completion_insert_mode + if LspCommitCompletionWithOppositeInsertMode.active: + insert_mode = 'replace' if insert_mode == 'insert' else 'insert' + return text_edit.get(insert_mode) # type: ignore + text_edit = cast(TextEdit, text_edit) + return text_edit['range'] + + class LspResolveDocsCommand(LspTextCommand): completions = {} # type: Dict[SessionName, List[CompletionItem]] @@ -77,12 +89,21 @@ def _on_navigate(self, url: str) -> None: webbrowser.open(url) +class LspCommitCompletionWithOppositeInsertMode(LspTextCommand): + active = False + + def run(self, edit: sublime.Edit, event: Optional[dict] = None) -> None: + LspCommitCompletionWithOppositeInsertMode.active = True + self.view.run_command("commit_completion") + LspCommitCompletionWithOppositeInsertMode.active = False + + class LspSelectCompletionItemCommand(LspTextCommand): def run(self, edit: sublime.Edit, item: CompletionItem, session_name: str) -> None: text_edit = item.get("textEdit") if text_edit: new_text = text_edit["newText"].replace("\r", "") - edit_region = range_to_region(Range.from_lsp(text_edit['range']), self.view) + edit_region = range_to_region(Range.from_lsp(get_text_edit_range(text_edit)), self.view) for region in self._translated_regions(edit_region): self.view.erase(edit, region) else: diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py index 623a5c5da..a21880987 100644 --- a/plugin/core/protocol.py +++ b/plugin/core/protocol.py @@ -323,6 +323,12 @@ class SemanticTokenModifiers: 'description': str }, total=False) +InsertReplaceEdit = TypedDict('InsertReplaceEdit', { + 'newText': str, + 'insert': RangeLsp, + 'replace': RangeLsp +}, total=True) + CompletionItem = TypedDict('CompletionItem', { 'additionalTextEdits': List[TextEdit], 'command': Command, @@ -341,7 +347,7 @@ class SemanticTokenModifiers: 'preselect': bool, 'sortText': str, 'tags': List[int], - 'textEdit': TextEdit + 'textEdit': Union[TextEdit, InsertReplaceEdit] }, total=False) CompletionList = TypedDict('CompletionList', { diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 28647c062..eecbf2fe0 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -246,6 +246,7 @@ def get_initialize_params(variables: Dict[str, str], workspace_folders: List[Wor "resolveSupport": { "properties": ["detail", "documentation", "additionalTextEdits"] }, + "insertReplaceSupport": True, "insertTextModeSupport": { "valueSet": [InsertTextMode.AdjustIndentation] }, diff --git a/plugin/core/types.py b/plugin/core/types.py index dd7d00779..1f9011dfe 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -195,6 +195,7 @@ class Settings: inhibit_snippet_completions = None # type: bool inhibit_word_completions = None # type: bool link_highlight_style = None # type: str + completion_insert_mode = None # type: str log_debug = None # type: bool log_max_size = None # type: int log_server = None # type: List[str] @@ -241,6 +242,7 @@ def r(name: str, default: Union[bool, int, str, list, dict]) -> None: r("lsp_format_on_save", False) r("on_save_task_timeout_ms", 2000) r("only_show_lsp_completions", False) + r("completion_insert_mode", 'insert') r("popup_max_characters_height", 1000) r("popup_max_characters_width", 120) r("semantic_highlighting", False) diff --git a/plugin/core/views.py b/plugin/core/views.py index 42d4cf8c5..8d98d1cfa 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -1015,6 +1015,10 @@ def format_completion( if item.get('deprecated') or CompletionItemTag.Deprecated in item.get('tags', []): annotation = "DEPRECATED - " + annotation if annotation else "DEPRECATED" + insert_replace_support_html = get_insert_replace_support_html(item) + if insert_replace_support_html: + details.append(insert_replace_support_html) + completion = sublime.CompletionItem.command_completion( trigger=trigger, command='lsp_select_completion_item', @@ -1039,3 +1043,13 @@ def format_code_actions_for_quick_panel( if code_action.get('isPreferred', False): selected_index = idx return items, selected_index + + +def get_insert_replace_support_html(item: CompletionItem) -> Optional[str]: + text_edit = item.get('textEdit') + if text_edit and 'insert' in text_edit and 'replace' in text_edit: + insert_mode = userprefs().completion_insert_mode + oposite_insert_mode = 'Replace' if insert_mode == 'insert' else 'Insert' + command_url = sublime.command_url("lsp_commit_completion_with_opposite_insert_mode") + return "{}".format(command_url, oposite_insert_mode) + return None diff --git a/sublime-package.json b/sublime-package.json index 1cd7743fb..77f059d39 100644 --- a/sublime-package.json +++ b/sublime-package.json @@ -464,6 +464,12 @@ "default": true, "markdownDescription": "Disable Sublime Text's word completions. \"word\" completion means Sublime Text's internal completer that takes words from the current buffer you're editing and presents them in the auto-complete widget." }, + "completion_insert_mode": { + "type": "string", + "default": "insert", + "enum": ["insert", "replace"], + "markdownDescription": "The mode used for inserting completions:\n\n - `insert` would insert the completion text in a middle of the word\n\n - `replace` would replace the existing word with a new completion text\n\n An LSP keybinding `lsp_commit_completion_with_opposite_insert_mode`\n can be used to insert completion using the opposite mode to the one selected here.\n\n Note: Must be supported by the language server." + }, "show_references_in_quick_panel": { "type": "boolean", "default": false, diff --git a/tests/test_completion.py b/tests/test_completion.py index 0afa86947..bb7bba9d2 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -47,7 +47,7 @@ def move_cursor(self, row: int, col: int) -> None: s.clear() s.add(point) - def create_commit_completion_closure(self) -> Callable[[], bool]: + def create_commit_completion_closure(self, commit_completion_command="commit_completion") -> Callable[[], bool]: committed = False current_change_count = self.view.change_count() @@ -57,7 +57,7 @@ def commit_completion() -> bool: nonlocal committed nonlocal current_change_count if not committed: - self.view.run_command("commit_completion") + self.view.run_command(commit_completion_command) committed = True return self.view.change_count() > current_change_count @@ -67,6 +67,10 @@ def select_completion(self) -> 'Generator': self.view.run_command('auto_complete') yield self.create_commit_completion_closure() + def shift_select_completion(self) -> 'Generator': + self.view.run_command('auto_complete') + yield self.create_commit_completion_closure("lsp_commit_completion_with_opposite_insert_mode") + def read_file(self) -> str: return self.view.substr(sublime.Region(0, self.view.size())) @@ -603,6 +607,36 @@ def test_text_edit_plaintext_with_multiple_lines_indented(self) -> Generator[Non # 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': + self.type('{{ title }}') + self.move_cursor(0, 5) # Put the cursor inbetween 'i' and 't' + self.set_response("textDocument/completion", [{ + 'label': 'title', + 'textEdit': { + 'newText': 'title', + 'insert': {'start': {'line': 0, 'character': 3}, 'end': {'line': 0, 'character': 5}}, + 'replace': {'start': {'line': 0, 'character': 3}, 'end': {'line': 0, 'character': 8}} + } + }]) + yield from self.select_completion() + yield from self.await_message("textDocument/completion") + self.assertEqual(self.read_file(), '{{ titletle }}') + + def test_replace_insert_mode(self) -> 'Generator': + self.type('{{ title }}') + self.move_cursor(0, 4) # Put the cursor inbetween 't' and 'i' + self.set_response("textDocument/completion", [{ + 'label': 'turtle', + 'textEdit': { + 'newText': 'turtle', + 'insert': {'start': {'line': 0, 'character': 3}, 'end': {'line': 0, 'character': 4}}, + '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") + self.assertEqual(self.read_file(), '{{ turtle }}') + def test_show_deprecated_flag(self) -> None: item_with_deprecated_flag = { "label": 'hello',