diff --git a/plugin/edit.py b/plugin/edit.py index 4497e09e8..23fa4595f 100644 --- a/plugin/edit.py +++ b/plugin/edit.py @@ -1,8 +1,9 @@ from .core.edit import TextEditTuple from .core.logging import debug -from .core.typing import List, Optional, Any, Generator, Iterable +from .core.typing import List, Optional, Any, Generator, Iterable, Tuple from contextlib import contextmanager import operator +import re import sublime import sublime_plugin @@ -21,8 +22,11 @@ def temporary_setting(settings: sublime.Settings, key: str, val: Any) -> Generat class LspApplyDocumentEditCommand(sublime_plugin.TextCommand): + re_placeholder = re.compile(r'\$(0|\{0:([^}]*)\})') - def run(self, edit: sublime.Edit, changes: Optional[List[TextEditTuple]] = None) -> None: + def run( + self, edit: sublime.Edit, changes: Optional[List[TextEditTuple]] = None, process_placeholders: bool = False + ) -> None: # Apply the changes in reverse, so that we don't invalidate the range # of any change that we haven't applied yet. if not changes: @@ -30,10 +34,27 @@ def run(self, edit: sublime.Edit, changes: Optional[List[TextEditTuple]] = None) with temporary_setting(self.view.settings(), "translate_tabs_to_spaces", False): view_version = self.view.change_count() last_row, _ = self.view.rowcol_utf16(self.view.size()) + placeholder_region_count = 0 for start, end, replacement, version in reversed(_sort_by_application_order(changes)): if version is not None and version != view_version: debug('ignoring edit due to non-matching document version') continue + placeholder_region = None # type: Optional[Tuple[Tuple[int, int], Tuple[int, int]]] + if process_placeholders and replacement: + parsed = self.parse_snippet(replacement) + if parsed: + replacement, (placeholder_start, placeholder_length) = parsed + # There might be newlines before the placeholder. Find the actual line + # and the character offset of the placeholder. + prefix = replacement[0:placeholder_start] + last_newline_start = prefix.rfind('\n') + start_line = start[0] + prefix.count('\n') + if last_newline_start == -1: + start_column = start[1] + placeholder_start + else: + start_column = len(prefix) - last_newline_start - 1 + end_column = start_column + placeholder_length + placeholder_region = ((start_line, start_column), (start_line, end_column)) region = sublime.Region( self.view.text_point_utf16(*start, clamp_column=True), self.view.text_point_utf16(*end, clamp_column=True) @@ -45,6 +66,16 @@ def run(self, edit: sublime.Edit, changes: Optional[List[TextEditTuple]] = None) last_row, _ = self.view.rowcol(self.view.size()) else: self.apply_change(region, replacement, edit) + if placeholder_region is not None: + if placeholder_region_count == 0: + self.view.sel().clear() + placeholder_region_count += 1 + self.view.sel().add(sublime.Region( + self.view.text_point_utf16(*placeholder_region[0], clamp_column=True), + self.view.text_point_utf16(*placeholder_region[1], clamp_column=True) + )) + if placeholder_region_count == 1: + self.view.show(self.view.sel()) def apply_change(self, region: sublime.Region, replacement: str, edit: sublime.Edit) -> None: if region.empty(): @@ -55,6 +86,15 @@ def apply_change(self, region: sublime.Region, replacement: str, edit: sublime.E else: self.view.erase(edit, region) + def parse_snippet(self, replacement: str) -> Optional[Tuple[str, Tuple[int, int]]]: + match = re.search(self.re_placeholder, replacement) + if not match: + return + placeholder = match.group(2) or '' + new_replacement = replacement.replace(match.group(0), placeholder) + placeholder_start_and_length = (match.start(0), len(placeholder)) + return (new_replacement, placeholder_start_and_length) + def _sort_by_application_order(changes: Iterable[TextEditTuple]) -> List[TextEditTuple]: # The spec reads: diff --git a/plugin/formatting.py b/plugin/formatting.py index 73287e98d..9542732b4 100644 --- a/plugin/formatting.py +++ b/plugin/formatting.py @@ -50,9 +50,11 @@ def format_document(text_command: LspTextCommand, formatter: Optional[str] = Non return Promise.resolve(None) -def apply_text_edits_to_view(response: Optional[List[TextEdit]], view: sublime.View) -> None: +def apply_text_edits_to_view( + response: Optional[List[TextEdit]], view: sublime.View, *, process_placeholders: bool = False +) -> None: edits = list(parse_text_edit(change) for change in response) if response else [] - view.run_command('lsp_apply_document_edit', {'changes': edits}) + view.run_command('lsp_apply_document_edit', {'changes': edits, 'process_placeholders': process_placeholders}) class WillSaveWaitTask(SaveTask): diff --git a/stubs/sublime.pyi b/stubs/sublime.pyi index 684d74184..5bf326a6a 100644 --- a/stubs/sublime.pyi +++ b/stubs/sublime.pyi @@ -1758,7 +1758,7 @@ class View: # def is_in_edit(self) -> bool: # undocumented # ... - def insert(self, edit: Edit, pt: int, text: str) -> None: + def insert(self, edit: Edit, pt: int, text: str) -> int: """ Insert the given string into the buffer. diff --git a/tests/test_edit.py b/tests/test_edit.py index aedeba603..1e0a94c1a 100644 --- a/tests/test_edit.py +++ b/tests/test_edit.py @@ -1,7 +1,9 @@ from LSP.plugin.core.edit import parse_workspace_edit, parse_text_edit from LSP.plugin.edit import _sort_by_application_order as sort_by_application_order from LSP.plugin.core.url import filename_to_uri +from LSP.plugin.core.views import entire_content from LSP.plugin.edit import temporary_setting +from setup import TextDocumentTestCase from test_protocol import LSP_RANGE import sublime import unittest @@ -233,6 +235,69 @@ def test_sorts_in_application_order2(self): self.assertEqual(sorted_edits[1][1], (27, 32)) +class ApplyDocumentEditTestCase(TextDocumentTestCase): + + def test_applies_text_edit(self) -> None: + self.insert_characters('abc') + edit = parse_text_edit({ + 'newText': 'x$0y', + 'range': { + 'start': { + 'line': 0, + 'character': 1, + }, + 'end': { + 'line': 0, + 'character': 2, + } + } + }) + self.view.run_command("lsp_apply_document_edit", {"changes": [edit]}) + self.assertEquals(entire_content(self.view), 'ax$0yc') + + def test_applies_text_edit_with_placeholder(self) -> None: + self.insert_characters('abc') + edit = parse_text_edit({ + 'newText': 'x$0y', + 'range': { + 'start': { + 'line': 0, + 'character': 1, + }, + 'end': { + 'line': 0, + 'character': 2, + } + } + }) + self.view.run_command('lsp_apply_document_edit', {'changes': [edit], 'process_placeholders': True}) + self.assertEquals(entire_content(self.view), 'axyc') + self.assertEqual(len(self.view.sel()), 1) + self.assertEqual(self.view.sel()[0], sublime.Region(2, 2)) + + def test_applies_multiple_text_edits_with_placeholders(self) -> None: + self.insert_characters('ab') + newline_edit = parse_text_edit({ + 'newText': '\n$0', + 'range': { + 'start': { + 'line': 0, + 'character': 1, + }, + 'end': { + 'line': 0, + 'character': 1, + } + } + }) + edits = [newline_edit, newline_edit] + self.view.run_command('lsp_apply_document_edit', {'changes': edits, 'process_placeholders': True}) + self.assertEquals(entire_content(self.view), 'a\n\nb') + self.assertEqual(len(self.view.sel()), 2) + self.assertEqual(self.view.sel()[0], sublime.Region(2, 2)) + self.assertEqual(self.view.sel()[1], sublime.Region(3, 3)) + + class TemporarySetting(unittest.TestCase): def test_basics(self) -> None: