From 8165c6989740a8b0c3fce605f92968a2336c20b8 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Fri, 23 Feb 2024 06:20:17 +0100 Subject: [PATCH 1/2] Add support for SnippetTextEdits --- plugin/core/edit.py | 11 ++++++--- plugin/core/protocol.py | 7 ++++++ plugin/core/sessions.py | 40 +++++++++++++++++++++++++++++- plugin/edit.py | 54 ++++++++++++++++++++++++++++++++++------- 4 files changed, 99 insertions(+), 13 deletions(-) diff --git a/plugin/core/edit.py b/plugin/core/edit.py index 48e509dfa..f50b3baf6 100644 --- a/plugin/core/edit.py +++ b/plugin/core/edit.py @@ -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: @@ -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 diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py index 85f878800..645df6c18 100644 --- a/plugin/core/protocol.py +++ b/plugin/core/protocol.py @@ -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] +}) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 6cc789e07..f2695d750 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -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 @@ -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 @@ -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 @@ -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": { @@ -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)) ) @@ -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'))) @@ -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}))) diff --git a/plugin/edit.py b/plugin/edit.py index f0f5dd15a..818f2066c 100644 --- a/plugin/edit.py +++ b/plugin/edit.py @@ -1,6 +1,9 @@ +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 @@ -8,7 +11,7 @@ import sublime_plugin -TextEditTuple = Tuple[Tuple[int, int], Tuple[int, int], str] +TextEditTuple = Tuple[Tuple[int, int], Tuple[int, int], str, bool] @contextmanager @@ -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: @@ -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) @@ -63,6 +73,15 @@ 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) @@ -70,10 +89,10 @@ def run( 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() @@ -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: @@ -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 ) From 10c46706922a64fe056083f8cfdc82fdc520b21f Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 24 Feb 2024 12:20:47 +0100 Subject: [PATCH 2/2] Update test --- tests/test_edit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_edit.py b/tests/test_edit.py index bd5d3b108..a9ebe0c26 100644 --- a/tests/test_edit.py +++ b/tests/test_edit.py @@ -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):