Repository: sublimelsp/LSP

Issue:
Add support for CompletionItem.textEdit (& additionalTextEdits)
* Windows 10 / macOS 10.13.3
* Latest LSP from Package Control
* EditorServiceHost (PowerShell) language server accoring to recipe at: https://github.com/PowerShell/PowerShellEditorServices/pull/629#issuecomment-373787797

LSP currently supports completion with `CompletionItem.insertText` only, while it may be good enough for some servers e.g. [python-language-server](https://github.com/palantir/python-language-server), for some cases like EditorServiceHost this is simply not enough as `insertText` requires some extra logic on editor side.

Concrete example for EditorServiceHost
1. `$tru`<kbd>Ctrl</kbd><kbd>Space</kbd> gets completed to `$$true` (double `$$`) instead of `$true`

    Payload:
    ~~~
    [
    {'filterText': '$true', 'insertText': '$true', 'sortText': '0001true', 'label': 'true', 'additionalTextEdits': None, 'detail': None, 'data': None, 'kind': 6, 'command': None,
     'textEdit': {'newText': '$true', 'range': {'end': {'line': 5, 'character': 2}, 'start': {'line': 5, 'character': 0}}}, 'commitCharacters': None, 'range': None, 'documentation': None}, {'filterText': '$tokens', 'insertText': '$tokens', 'sortText': '0002tokens', 'label': 'tokens', 'additionalTextEdits': None, 'detail': '[Token[]]', 'data': None, 'kind': 6, 'command': None, 'textEdit': {'newText': '$tokens', 'range': {'end': {'line': 5, 'character': 2}, 'start': {'line': 5, 'character': 0}}}, 'commitCharacters': None, 'range': None, 'documentation': None}, {'filterText': '$this', 'insertText': '$this', 'sortText': '0003this', 'label': 'this', 'additionalTextEdits': None, 'detail': None, 'data': None, 'kind': 6, 'command': None, 'textEdit': {'newText': '$this', 'range': {'end': {'line': 5, 'character': 2}, 'start': {'line': 5, 'character': 0}}}, 'commitCharacters': None, 'range': None, 'documentation': None}
    ]
    ~~~

2. `$env:U`<kbd>Ctrl</kbd><kbd>Space</kbd> gets completed to `$env:$env:USERPROFILE` instead of `$env:USERPROFILE`:

    Payload:
    ~~~
    [
    {'filterText': '$env:UATDATA', 'insertText': '$env:UATDATA', 'sortText': '0001UATDATA', 'label': 'UATDATA', 'additionalTextEdits': None, 'detail': None, 'data': None, 'kind': 6, 'command': None, 'textEdit': {'newText': '$env:UATDATA', 'range': {'end': {'line': 5, 'character': 6}, 'start': {'line': 5, 'character': 0}}}, 'commitCharacters': None, 'range': None, 'documentation': None}, {'filterText': '$env:USERDNSDOMAIN', 'insertText': '$env:USERDNSDOMAIN', 'sortText': '0002USERDNSDOMAIN', 'label': 'USERDNSDOMAIN', 'additionalTextEdits': None, 'detail': None, 'data': None, 'kind': 6, 'command': None, 'textEdit': {'newText': '$env:USERDNSDOMAIN', 'range': {'end': {'line': 5, 'character': 6}, 'start': {'line': 5, 'character': 0}}}, 'commitCharacters': None, 'range': None, 'documentation': None}, {'filterText': '$env:USERDOMAIN', 'insertText': '$env:USERDOMAIN', 'sortText': '0003USERDOMAIN', 'label': 'USERDOMAIN', 'additionalTextEdits': None, 'detail': None, 'data': None, 'kind': 6, 'command': None,
     'textEdit': {'newText': '$env:USERDOMAIN', 'range': {'end': {'line': 5, 'character': 6}, 'start': {'line': 5, 'character': 0}}}, 'commitCharacters': None, 'range': None, 'documentation': None},
    {'filterText': '$env:USERDOMAIN_ROAMINGPROFILE', 'insertText': '$env:USERDOMAIN_ROAMINGPROFILE', 'sortText': '0004USERDOMAIN_ROAMINGPROFILE', 'label': 'USERDOMAIN_ROAMINGPROFILE', 'additionalTextEdits': None, 'detail': None, 'data': None, 'kind': 6, 'command': None, 'textEdit': {'newText': '$env:USERDOMAIN_ROAMINGPROFILE', 'range': {'end': {'line': 5, 'character': 6}, 'start': {'line': 5, 'character': 0}}}, 'commitCharacters': None, 'range': None, 'documentation': None}, {'filterText': '$env:USERNAME', 'insertText': '$env:USERNAME', 'sortText': '0005USERNAME', 'label': 'USERNAME', 'additionalTextEdits': None, 'detail': None, 'data': None, 'kind': 6, 'command': None, 
     'textEdit': {'newText': '$env:USERNAME', 'range': {'end': {'line': 5, 'character': 6}, 'start': {'line': 5, 'character': 0}}}, 'commitCharacters': None, 'range': None, 'documentation': None},
    {'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': 5, 'character': 6}, 'start': {'line': 5, 'character': 0}}}, 'commitCharacters': None, 'range': None, 'documentation': None}
   ]
   ~~~

Quoting https://microsoft.github.io/language-server-protocol/specification:

> The `insertText` is subject to interpretation by the client side. Some tools might not take the string literally. For example VS Code when code complete is requested in this example `con<cursor position>`  and a completion item with an `insertText` of `console` is provided it will only insert `sole`. Therefore it is recommended to use `textEdit` instead since it avoids additional client side interpretation.

Therefore for to avoid additional logic and misinterpretations `textEdit` (when provided by server) should be supported and used instead `insertText` by LSP and also (optionally `additionalTextEdits`).


Discussion:

Resolving patch:
diff --git a/plugin/completion.py b/plugin/completion.py
--- a/plugin/completion.py
+++ b/plugin/completion.py
@@ -10,7 +10,7 @@
 from .core.protocol import Request
 from .core.settings import settings
 from .core.logging import debug, exception_log
-from .core.protocol import CompletionItemKind
+from .core.protocol import CompletionItemKind, Range
 from .core.clients import client_for_view
 from .core.configurations import is_supported_syntax
 from .core.documents import get_document_position, purge_did_change
@@ -241,13 +241,29 @@ def format_completion(self, item: dict) -> 'Tuple[str, str]':
             kind = item.get("kind")
             if kind:
                 hint = completion_item_kind_names[kind]
-        # label is an alternative for insertText if insertText not provided
-        insert_text = item.get("insertText") or label
+        # label is an alternative for insertText if neither textEdit nor insertText is provided
+        insert_text = self.text_edit_text(item) or item.get("insertText") or label
         if len(insert_text) > 0 and insert_text[0] == '$':  # sublime needs leading '$' escaped.
-            insert_text = '\$' + insert_text[1:]
+            insert_text = '\\$' + insert_text[1:]
         # only return label with a hint if available
         return "\t  ".join((label, hint)) if hint else label, insert_text
 
+    def text_edit_text(self, item) -> 'Optional[str]':
+        # try to handle textEdit if present
+        text_edit = item.get("textEdit")
+        if text_edit:
+            edit_range, edit_text = text_edit.get("range"), text_edit.get("newText")
+            if edit_range and edit_text:
+                edit_range = Range.from_lsp(edit_range)
+                last_start = self.last_location - len(self.last_prefix)
+                last_row, last_col = self.view.rowcol(last_start)
+                if last_row == edit_range.start.row == edit_range.end.row and edit_range.start.col <= last_col:
+                    # sublime does not support explicit replacement with completion
+                    # at given range, but we try to trim the textEdit range and text
+                    # to the start location of the completion
+                    return edit_text[last_col - edit_range.start.col:]
+        return None
+
     def handle_response(self, response: 'Optional[Dict]'):
         global resolvable_completion_items
 


Test patch: