Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for SnippetTextEdits #2426

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions plugin/core/edit.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
from .logging import debug
from .protocol import Position
from .protocol import SnippetTextEdit
from .protocol import TextEdit
from .protocol import UINT_MAX
from .protocol import WorkspaceEdit
from .typing import List, Dict, Optional, Tuple
from .typing import List, Dict, Optional, Tuple, TypeGuard, Union
import sublime


WorkspaceChanges = Dict[str, Tuple[List[TextEdit], Optional[int]]]
WorkspaceChanges = Dict[str, Tuple[List[Union[TextEdit, SnippetTextEdit]], Optional[int]]]


def is_snippet_text_edit(edit: Union[TextEdit, SnippetTextEdit]) -> TypeGuard[SnippetTextEdit]:
return 'snippet' in edit and 'newText' not in edit


def parse_workspace_edit(workspace_edit: WorkspaceEdit) -> WorkspaceChanges:
Expand All @@ -28,7 +33,7 @@ def parse_workspace_edit(workspace_edit: WorkspaceEdit) -> WorkspaceChanges:
raw_changes = workspace_edit.get('changes')
if isinstance(raw_changes, dict):
for uri, edits in raw_changes.items():
changes[uri] = (edits, None)
changes[uri] = (edits, None) # type: ignore
return changes


Expand Down
7 changes: 7 additions & 0 deletions plugin/core/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -6335,3 +6335,10 @@ def to_lsp(self) -> 'Position':

# Temporary for backward compatibility with LSP packages.
RangeLsp = Range

# Temporary for this PR as long as protocol types are not yet updated
SnippetTextEdit = TypedDict('SnippetTextEdit', {
'range': Range,
'snippet': Dict[str, str],
'annotationId': NotRequired[ChangeAnnotationIdentifier]
})
40 changes: 39 additions & 1 deletion plugin/core/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .constants import SEMANTIC_TOKENS_MAP
from .diagnostics_storage import DiagnosticsStorage
from .edit import apply_text_edits
from .edit import is_snippet_text_edit
from .edit import parse_workspace_edit
from .edit import WorkspaceChanges
from .file_watcher import DEFAULT_KIND
Expand All @@ -18,6 +19,7 @@
from .progress import WindowProgressReporter
from .promise import PackagedTask
from .promise import Promise
from .protocol import ApplyWorkspaceEditParams
from .protocol import ClientCapabilities
from .protocol import CodeAction, CodeActionKind
from .protocol import CodeLensExtended
Expand Down Expand Up @@ -63,6 +65,7 @@
from .protocol import ResponseError
from .protocol import SemanticTokenModifiers
from .protocol import SemanticTokenTypes
from .protocol import SnippetTextEdit
from .protocol import SymbolKind
from .protocol import SymbolTag
from .protocol import TextDocumentClientCapabilities
Expand Down Expand Up @@ -460,6 +463,7 @@ def get_initialize_params(variables: Dict[str, str], workspace_folders: List[Wor
"workspaceEdit": {
"documentChanges": True,
"failureHandling": FailureHandlingKind.Abort,
"snippetEditSupport": True
},
"workspaceFolders": True,
"symbol": {
Expand Down Expand Up @@ -1773,6 +1777,24 @@ def apply_workspace_edit_async(self, edit: WorkspaceEdit) -> Promise[None]:
def apply_parsed_workspace_edits(self, changes: WorkspaceChanges) -> Promise[None]:
promises = [] # type: List[Promise[None]]
for uri, (edits, view_version) in changes.items():
if any(is_snippet_text_edit(edit) for edit in edits):
# SnippetTextEdits must only be applied to the active view
window = sublime.active_window()
if window.is_valid():
view = window.active_view()
if view and view.settings().get('lsp_uri') == uri:
promises.append(self._apply_text_edits_with_snippet(edits, view_version, view))
continue
# > In case the snippet text edit corresponds to a file that is not currently open in the active editor,
# > the client should downgrade the snippet to a non-interactive normal text edit and apply it to the
# > file.
for edit in edits:
if is_snippet_text_edit(edit):
new_text = sublime.expand_variables(edit['snippet']['value'], {})
del edit['snippet'] # type: ignore
edit = cast(TextEdit, edit)
edit['newText'] = new_text
edits = cast(List[TextEdit], edits)
promises.append(
self.open_uri_async(uri).then(functools.partial(self._apply_text_edits, edits, view_version, uri))
)
Expand All @@ -1786,6 +1808,22 @@ def _apply_text_edits(
return
apply_text_edits(view, edits, required_view_version=view_version)

def _apply_text_edits_with_snippet(
self,
edits: List[Union[TextEdit, SnippetTextEdit]],
view_version: Optional[int],
view: sublime.View
) -> Promise[None]:
view.run_command(
'lsp_apply_document_edit',
{
'changes': edits,
'process_placeholders': False,
'required_view_version': view_version,
}
)
return Promise.resolve(None)

def decode_semantic_token(
self, token_type_encoded: int, token_modifiers_encoded: int) -> Tuple[str, List[str], Optional[str]]:
types_legend = tuple(cast(List[str], self.get_capability('semanticTokensProvider.legend.tokenTypes')))
Expand Down Expand Up @@ -1912,7 +1950,7 @@ def m_workspace_configuration(self, params: Dict[str, Any], request_id: Any) ->
items.append(configuration)
self.send_response(Response(request_id, sublime.expand_variables(items, self._template_variables())))

def m_workspace_applyEdit(self, params: Any, request_id: Any) -> None:
def m_workspace_applyEdit(self, params: ApplyWorkspaceEditParams, request_id: Any) -> None:
"""handles the workspace/applyEdit request"""
self.apply_workspace_edit_async(params.get('edit', {})).then(
lambda _: self.send_response(Response(request_id, {"applied": True})))
Expand Down
54 changes: 45 additions & 9 deletions plugin/edit.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from .core.edit import is_snippet_text_edit
from .core.edit import parse_range
from .core.protocol import SnippetTextEdit
from .core.protocol import TextEdit
from .core.typing import List, Optional, Any, Generator, Iterable, Tuple
from .core.typing import List, Optional, Any, Generator, Iterable, Tuple, Union
from .core.typing import cast
from contextlib import contextmanager
import operator
import re
import sublime
import sublime_plugin


TextEditTuple = Tuple[Tuple[int, int], Tuple[int, int], str]
TextEditTuple = Tuple[Tuple[int, int], Tuple[int, int], str, bool]


@contextmanager
Expand All @@ -30,7 +33,7 @@ class LspApplyDocumentEditCommand(sublime_plugin.TextCommand):
def run(
self,
edit: sublime.Edit,
changes: List[TextEdit],
changes: List[Union[TextEdit, SnippetTextEdit]],
required_view_version: Optional[int] = None,
process_placeholders: bool = False,
) -> None:
Expand All @@ -42,11 +45,18 @@ def run(
if required_view_version is not None and required_view_version != view_version:
print('LSP: ignoring edit due to non-matching document version')
return
edits = [_parse_text_edit(change) for change in changes or []]
edits = [] # type: List[TextEditTuple]
for change in changes:
if is_snippet_text_edit(change):
edits.append(_parse_snippet_text_edit(change))
else:
change = cast(TextEdit, change)
edits.append(_parse_text_edit(change))
with temporary_setting(self.view.settings(), "translate_tabs_to_spaces", False):
last_row, _ = self.view.rowcol_utf16(self.view.size())
placeholder_region_count = 0
for start, end, replacement in reversed(_sort_by_application_order(edits)):
snippet_already_applied = False
for start, end, replacement, is_snippet in reversed(_sort_by_application_order(edits)):
placeholder_region = None # type: Optional[Tuple[Tuple[int, int], Tuple[int, int]]]
if process_placeholders and replacement:
parsed = self.parse_snippet(replacement)
Expand All @@ -63,17 +73,26 @@ def run(
start_column = len(prefix) - last_newline_start - 1
end_column = start_column + placeholder_length
placeholder_region = ((start_line, start_column), (start_line, end_column))
if is_snippet:
if snippet_already_applied:
# Downgrade to regular TextEdit
# > For the active file, only one snippet can specify a cursor position. In case there are
# > multiple snippets defining a cursor position for a given URI, it is up to the client to
# > decide the end position of the cursor.
is_snippet = False
replacement = sublime.expand_variables(replacement, {})
snippet_already_applied = True
region = sublime.Region(
self.view.text_point_utf16(*start, clamp_column=True),
self.view.text_point_utf16(*end, clamp_column=True)
)
if start[0] > last_row and replacement[0] != '\n':
# Handle when a language server (eg gopls) inserts at a row beyond the document
# some editors create the line automatically, sublime needs to have the newline prepended.
self.apply_change(region, '\n' + replacement, edit)
self.apply_change(edit, region, '\n' + replacement, is_snippet)
last_row, _ = self.view.rowcol(self.view.size())
else:
self.apply_change(region, replacement, edit)
self.apply_change(edit, region, replacement, is_snippet)
if placeholder_region is not None:
if placeholder_region_count == 0:
self.view.sel().clear()
Expand All @@ -85,7 +104,13 @@ def run(
if placeholder_region_count == 1:
self.view.show(self.view.sel())

def apply_change(self, region: sublime.Region, replacement: str, edit: sublime.Edit) -> None:
def apply_change(self, edit: sublime.Edit, region: sublime.Region, replacement: str, is_snippet: bool) -> None:
if is_snippet:
selection = self.view.sel()
selection.clear()
selection.add(region)
self.view.run_command('insert_snippet', {'contents': replacement})
return
if region.empty():
self.view.insert(edit, region.a, replacement)
else:
Expand All @@ -109,7 +134,18 @@ def _parse_text_edit(text_edit: TextEdit) -> TextEditTuple:
parse_range(text_edit['range']['start']),
parse_range(text_edit['range']['end']),
# Strip away carriage returns -- SublimeText takes care of that.
text_edit.get('newText', '').replace("\r", "")
text_edit.get('newText', '').replace("\r", ""),
False
)


def _parse_snippet_text_edit(text_edit: SnippetTextEdit) -> TextEditTuple:
return (
parse_range(text_edit['range']['start']),
parse_range(text_edit['range']['end']),
# Strip away carriage returns -- SublimeText takes care of that.
text_edit['snippet']['value'].replace("\r", ""),
True
)


Expand Down
3 changes: 2 additions & 1 deletion tests/test_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,13 @@
class TextEditTests(unittest.TestCase):

def test_parse_from_lsp(self):
(start, end, newText) = parse_text_edit(LSP_TEXT_EDIT)
(start, end, newText, is_snippet) = parse_text_edit(LSP_TEXT_EDIT)
self.assertEqual(newText, 'newText\n') # Without the \r
self.assertEqual(start[0], 10)
self.assertEqual(start[1], 4)
self.assertEqual(end[0], 11)
self.assertEqual(end[1], 3)
self.assertEqual(is_snippet, False)


class WorkspaceEditTests(unittest.TestCase):
Expand Down
Loading