diff --git a/plugin/completion.py b/plugin/completion.py index f6648f3df..4544645d3 100644 --- a/plugin/completion.py +++ b/plugin/completion.py @@ -1,3 +1,4 @@ +import html import sublime import sublime_plugin @@ -192,6 +193,7 @@ def on_query_completions(self, prefix: str, locations: List[int]) -> Optional[su return completion_list def format_completion(self, item: dict) -> sublime.CompletionItem: + # This is a hot function. Don't do heavy computations or IO in this function. item_kind = item.get("kind") if item_kind: kind = completion_kinds.get(item_kind, sublime.KIND_AMBIGUOUS) @@ -220,12 +222,25 @@ def format_completion(self, item: dict) -> sublime.CompletionItem: convert = self.view.text_point_utf16 item["native_region"] = (convert(row, start_col_utf16), convert(row, end_col_utf16)) + lsp_label = item["label"] + lsp_filter_text = item.get("filterText") + lsp_detail = item.get("detail") or "" + if lsp_filter_text: + st_trigger = lsp_filter_text + st_annotation = lsp_label + st_details = html.escape(lsp_detail.replace('\n', ' ')) + else: + st_trigger = lsp_label + st_annotation = lsp_detail + st_details = '' + return sublime.CompletionItem.command_completion( - trigger=item["label"], + trigger=st_trigger, command="lsp_select_completion_item", args=item, - annotation=item.get('detail') or "", - kind=kind + annotation=st_annotation, + kind=kind, + details=st_details ) def handle_response(self, response: Optional[Union[dict, List]], completion_list: sublime.CompletionList) -> None: diff --git a/stubs/sublime.pyi b/stubs/sublime.pyi index 535dcd0a7..6b2a4aa6a 100644 --- a/stubs/sublime.pyi +++ b/stubs/sublime.pyi @@ -264,7 +264,8 @@ class CompletionItem: command: str, args: dict = {}, annotation: str = "", - kind: Tuple[int, str, str] = KIND_AMBIGUOUS + kind: Tuple[int, str, str] = KIND_AMBIGUOUS, + details: str = "" ) -> 'CompletionItem': ... diff --git a/tests/test_completion.py b/tests/test_completion.py index cbe196367..4bd4efe7c 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -4,7 +4,6 @@ from setup import CI, SUPPORTED_SYNTAX, TextDocumentTestCase, add_config, remove_config, text_config from unittesting import DeferrableTestCase import sublime -from sublime_plugin import view_event_listeners, ViewEventListener additional_edits = { @@ -160,83 +159,124 @@ def test_var_prefix_using_label(self) -> 'Generator': def test_var_prefix_added_in_insertText(self) -> 'Generator': """ - Powershell: label='true', insertText='$true' (see https://github.com/sublimelsp/LSP/issues/294) + https://github.com/sublimelsp/LSP/issues/294 + + User types '$env:U', server replaces '$env:U' with '$env:USERPROFILE' """ yield from self.verify( completion_items=[{ - "insertText": "$true", - "label": "true", - "textEdit": { - "newText": "$true", - "range": { - "end": { - "character": 5, - "line": 0 - }, - "start": { - "character": 0, - "line": 0 - } + 'filterText': '$env:USERPROFILE', + 'insertText': '$env:USERPROFILE', + 'sortText': '0006USERPROFILE', + 'label': 'USERPROFILE', + 'additionalTextEdits': None, + 'detail': None, + 'data': None, + 'kind': 6, + 'command': None, + 'textEdit': { + 'newText': '$env:USERPROFILE', + 'range': { + 'end': {'line': 0, 'character': 6}, + 'start': {'line': 0, 'character': 0} } - } + }, + 'commitCharacters': None, + 'range': None, + 'documentation': None }], - insert_text="$", - expected_text="$true") + insert_text="$env:U", + expected_text="$env:USERPROFILE") - def test_var_prefix_added_in_label(self) -> 'Generator': + def test_pure_insertion_text_edit(self) -> 'Generator': """ - PHP language server: label='$someParam', textEdit='someParam' (https://github.com/sublimelsp/LSP/issues/368) + https://github.com/sublimelsp/LSP/issues/368 + + User types '$so', server returns pure insertion completion 'meParam', completing it to '$someParam'. + + THIS TEST FAILS """ yield from self.verify( completion_items=[{ - 'label': '$what', 'textEdit': { + 'newText': 'meParam', 'range': { - 'start': { - 'line': 0, - 'character': 0 - }, - 'end': { - 'line': 0, - 'character': 1 - } - }, - 'newText': '$what' - } + 'end': {'character': 4, 'line': 0}, + 'start': {'character': 4, 'line': 0} # pure insertion! + } + }, + 'label': '$someParam', + 'filterText': None, + 'data': None, + 'command': None, + 'detail': 'null', + 'insertText': None, + 'additionalTextEdits': None, + 'sortText': None, + 'documentation': None, + 'kind': 6 }], - insert_text="$", - expected_text="$what") + insert_text="$so", + expected_text="$someParam") def test_space_added_in_label(self) -> 'Generator': """ Clangd: label=" const", insertText="const" (https://github.com/sublimelsp/LSP/issues/368) """ yield from self.verify( - completion_items=[{'label': ' const', 'insertText': 'const'}], - insert_text='', - expected_text="const") + completion_items=[{ + "label": " const", + "sortText": "3f400000const", + "kind": 14, + "textEdit": { + "newText": "const", + "range": { + "end": { + "character": 1, + "line": 0 + }, + "start": { + "character": 3, + "line": 0 + } + } + }, + "insertTextFormat": 2, + "insertText": "const", + "filterText": "const", + "score": 6 + }], + insert_text=' co', + expected_text=" const") # NOT 'const' def test_dash_missing_from_label(self) -> 'Generator': """ - Powershell: label="UniqueId", insertText="-UniqueId" (https://github.com/sublimelsp/LSP/issues/572) + Powershell: label="UniqueId", trigger="-UniqueIdd, text to be inserted = "-UniqueId" + + (https://github.com/sublimelsp/LSP/issues/572) """ yield from self.verify( completion_items=[{ - 'label': 'UniqueId', - 'insertText': '-UniqueId', - 'textEdit': { - 'range': { - 'start': { - 'character': 0, - 'line': 0 - }, - 'end': { - 'character': 1, - 'line': 0 - } + "filterText": "-UniqueId", + "documentation": None, + "textEdit": { + "range": { + "start": {"character": 14, "line": 0}, + "end": {"character": 15, "line": 0} }, - 'newText': '-UniqueId' - } + "newText": "-UniqueId" + }, + "commitCharacters": None, + "command": None, + "label": "UniqueId", + "insertText": "-UniqueId", + "additionalTextEdits": None, + "data": None, + "range": None, + "insertTextFormat": 1, + "sortText": "0001UniqueId", + "kind": 6, + "detail": "[string[]]" }], insert_text="u", expected_text="-UniqueId") @@ -268,55 +308,74 @@ def test_edit_before_cursor(self) -> 'Generator': def test_edit_after_nonword(self) -> 'Generator': """ - Metals: List.| selects label instead of textedit - See https://github.com/sublimelsp/LSP/issues/645 + https://github.com/sublimelsp/LSP/issues/645 """ yield from self.verify( completion_items=[{ - 'insertTextFormat': 2, - 'label': 'apply[A](xs: A*): List[A]', - 'textEdit': { - 'newText': 'apply($0)', - 'range': { - 'start': { - 'line': 0, - 'character': 5 + "textEdit": { + "newText": "apply($0)", + "range": { + "end": { + "line": 0, + "character": 5 }, - 'end': { - 'line': 0, - 'character': 5 + "start": { + "line": 0, + "character": 5 } } - } + }, + "label": "apply[A](xs: A*): List[A]", + "sortText": "00000", + "preselect": True, + "insertTextFormat": 2, + "filterText": "apply", + "data": { + "symbol": "scala/collection/immutable/List.apply().", + "target": "file:/home/user/workspace/testproject/?id=root" + }, + "kind": 2 }], insert_text="List.", expected_text='List.apply()') - def test_implement_all_members_quirk(self) -> 'Generator': + def test_filter_text_is_not_a_prefix_of_label(self) -> 'Generator': """ - Metals: "Implement all members" should just select the newText. + Metals: "Implement all members" + + The filterText is 'e', so when the user types 'e', one of the completion items should be + "Implement all members". + + VSCode doesn't show the filterText in this case; it'll only show "Implement all members". + c.f. https://github.com/microsoft/language-server-protocol/issues/898#issuecomment-593968008 + + In SublimeText, we always show the filterText (a.k.a. trigger). + + This is one of the more confusing and contentious completion items. + https://github.com/sublimelsp/LSP/issues/771 """ yield from self.verify( completion_items=[{ - 'insertTextFormat': 2, - 'label': 'Implement all members', - 'textEdit': { - 'newText': 'def foo: Int \u003d ${0:???}\n def boo: Int \u003d ${0:???}', - 'range': { - 'start': { - 'line': 0, - 'character': 0 - }, - 'end': { - 'line': 0, - 'character': 1 - } - } + "label": "Implement all members", + "kind": 12, + "sortText": "00002", + "filterText": "e", + "insertTextFormat": 2, + "textEdit": { + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 1} + }, + "newText": "def foo: Int \u003d ${0:???}\n def boo: Int \u003d ${0:???}" + }, + "data": { + "target": "file:/Users/ckipp/Documents/scala-workspace/test-project/?id\u003droot", + "symbol": "local6" } }], - insert_text="I", - expected_text='def foo: Int = ???\n def boo: Int = ???') + insert_text='e', + expected_text='def foo: Int \u003d ???\n def boo: Int \u003d ???') def test_additional_edits(self) -> 'Generator': yield from self.verify(