From b12404d5458ed55dcc50e55fd7d1c81196f62bcc Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Wed, 18 Aug 2021 16:39:28 +0200 Subject: [PATCH] Fix inconsistency in indent adjustment behavior when completing There is a small inconsistency when we're dealing with a text edit and the text edit is not a snippet. In that case, if the new text of the completion contained newlines, the subsequent line would not be "auto-indented". Whereas with the three other possible cases, it would be auto-indented. That is because the other three cases use the "insert" and "insert_snippet" commands, whereas with this special case we were using the view.insert method. --- plugin/completion.py | 66 ++++++++++++++++++---------------------- tests/test_completion.py | 25 ++++++++++++--- 2 files changed, 50 insertions(+), 41 deletions(-) diff --git a/plugin/completion.py b/plugin/completion.py index 47051839e..d188b1ab7 100644 --- a/plugin/completion.py +++ b/plugin/completion.py @@ -9,6 +9,7 @@ from .core.views import range_to_region from .core.views import show_lsp_popup from .core.views import update_lsp_popup +import functools SessionName = str @@ -72,49 +73,29 @@ def run(self, edit: sublime.Edit, item: CompletionItem, session_name: str) -> No if text_edit: new_text = text_edit["newText"].replace("\r", "") edit_region = range_to_region(Range.from_lsp(text_edit['range']), self.view) - if item.get("insertTextFormat", InsertTextFormat.PlainText) == InsertTextFormat.Snippet: - for region in self.translated_regions(edit_region): - self.view.erase(edit, region) - self.view.run_command("insert_snippet", {"contents": new_text}) - else: - for region in self.translated_regions(edit_region): - # NOTE: Cannot do .replace, because ST will select the replacement. - self.view.erase(edit, region) - self.view.insert(edit, region.a, new_text) + for region in self._translated_regions(edit_region): + self.view.erase(edit, region) else: - insert_text = item.get("insertText") or item["label"] - insert_text = insert_text.replace("\r", "") - if item.get("insertTextFormat", InsertTextFormat.PlainText) == InsertTextFormat.Snippet: - self.view.run_command("insert_snippet", {"contents": insert_text}) - else: - self.view.run_command("insert", {"characters": insert_text}) - self.epilogue(item, session_name) - - def translated_regions(self, edit_region: sublime.Region) -> Generator[sublime.Region, None, None]: - selection = self.view.sel() - primary_cursor_position = selection[0].b - for region in reversed(selection): - # For each selection region, apply the same removal as for the "primary" region. - # To do that, translate, or offset, the LSP edit region into the non-"primary" regions. - # The concept of "primary" is our own, and there is no mention of it in the LSP spec. - translation = region.b - primary_cursor_position - translated_edit_region = sublime.Region(edit_region.a + translation, edit_region.b + translation) - yield translated_edit_region - - def epilogue(self, item: CompletionItem, session_name: str) -> None: + new_text = item.get("insertText") or item["label"] + new_text = new_text.replace("\r", "") + if item.get("insertTextFormat", InsertTextFormat.PlainText) == InsertTextFormat.Snippet: + 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') - - def resolve_on_main_thread(item: CompletionItem, session_name: str) -> None: - sublime.set_timeout(lambda: self.on_resolved(item, session_name)) - additional_text_edits = item.get('additionalTextEdits') if session and not additional_text_edits: - request = Request.resolveCompletionItem(item, self.view) - session.send_request_async(request, lambda response: resolve_on_main_thread(response, session_name)) + session.send_request_async( + Request.resolveCompletionItem(item, self.view), + functools.partial(self._on_resolved_async, session_name)) else: - self.on_resolved(item, session_name) + self._on_resolved(session_name, item) - def on_resolved(self, item: CompletionItem, session_name: str) -> None: + def _on_resolved_async(self, session_name: str, item: CompletionItem) -> None: + sublime.set_timeout(functools.partial(self._on_resolved, session_name, item)) + + def _on_resolved(self, session_name: str, item: CompletionItem) -> None: additional_edits = item.get('additionalTextEdits') if additional_edits: edits = [parse_text_edit(additional_edit) for additional_edit in additional_edits] @@ -128,3 +109,14 @@ def on_resolved(self, item: CompletionItem, session_name: str) -> None: "session_name": session_name } self.view.run_command("lsp_execute", args) + + def _translated_regions(self, edit_region: sublime.Region) -> Generator[sublime.Region, None, None]: + selection = self.view.sel() + primary_cursor_position = selection[0].b + for region in reversed(selection): + # For each selection region, apply the same removal as for the "primary" region. + # To do that, translate, or offset, the LSP edit region into the non-"primary" regions. + # The concept of "primary" is our own, and there is no mention of it in the LSP spec. + translation = region.b - primary_cursor_position + translated_edit_region = sublime.Region(edit_region.a + translation, edit_region.b + translation) + yield translated_edit_region diff --git a/tests/test_completion.py b/tests/test_completion.py index f6c70ed82..55a279a0d 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -2,6 +2,7 @@ from LSP.plugin.core.protocol import CompletionItem from LSP.plugin.core.protocol import CompletionItemLabelDetails from LSP.plugin.core.protocol import CompletionItemTag +from LSP.plugin.core.protocol import InsertTextFormat from LSP.plugin.core.typing import Any, Generator, List, Dict, Callable, Optional from LSP.plugin.core.views import format_completion from setup import TextDocumentTestCase @@ -212,7 +213,7 @@ def test_space_added_in_label(self) -> 'Generator': } } }, - "insertTextFormat": 2, + "insertTextFormat": InsertTextFormat.Snippet, "insertText": "const", "filterText": "const", "score": 6 @@ -244,7 +245,7 @@ def test_dash_missing_from_label(self) -> 'Generator': "additionalTextEdits": None, "data": None, "range": None, - "insertTextFormat": 1, + "insertTextFormat": InsertTextFormat.PlainText, "sortText": "0001UniqueId", "kind": 6, "detail": "[string[]]" @@ -309,7 +310,7 @@ def test_edit_after_nonword(self) -> 'Generator': "label": "apply[A](xs: A*): List[A]", "sortText": "00000", "preselect": True, - "insertTextFormat": 2, + "insertTextFormat": InsertTextFormat.Snippet, "filterText": "apply", "data": { "symbol": "scala/collection/immutable/List.apply().", @@ -342,7 +343,7 @@ def test_filter_text_is_not_a_prefix_of_label(self) -> 'Generator': "kind": 12, "sortText": "00002", "filterText": "e", - "insertTextFormat": 2, + "insertTextFormat": InsertTextFormat.Snippet, "textEdit": { "range": { "start": {"line": 0, "character": 0}, @@ -588,6 +589,22 @@ def test_nontrivial_text_edit_removal_with_buffer_modifications_json(self) -> 'G yield self.create_commit_completion_closure() self.assertEqual(self.read_file(), '{"keys": []}') + def test_text_edit_plaintext_with_multiple_lines_indented(self) -> Generator[None, None, None]: + self.type("\t\n\t") + self.move_cursor(1, 2) + self.set_response("textDocument/completion", [{ + 'label': 'a', + 'textEdit': { + 'range': {'start': {'line': 1, 'character': 4}, 'end': {'line': 1, 'character': 4}}, + 'newText': 'a\n\tb' + }, + 'insertTextFormat': InsertTextFormat.PlainText + }]) + yield from self.select_completion() + yield from 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_show_deprecated_flag(self) -> None: item_with_deprecated_flag = { "label": 'hello',