Skip to content

Commit

Permalink
feat: support setting cursor position in text edits (#2389)
Browse files Browse the repository at this point in the history
  • Loading branch information
rchl committed Jan 13, 2024
1 parent ffe8f91 commit 2910042
Show file tree
Hide file tree
Showing 4 changed files with 112 additions and 5 deletions.
44 changes: 42 additions & 2 deletions plugin/edit.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -21,19 +22,39 @@ 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:
return
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)
Expand All @@ -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():
Expand All @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions plugin/formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion stubs/sublime.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 65 additions & 0 deletions tests/test_edit.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down

0 comments on commit 2910042

Please sign in to comment.