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

Enhancements for the rename panel #2428

Merged
merged 16 commits into from
Mar 12, 2024
2 changes: 2 additions & 0 deletions boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from .plugin.documents import DocumentSyncListener
from .plugin.documents import TextChangeListener
from .plugin.edit import LspApplyDocumentEditCommand
from .plugin.edit import LspApplyWorkspaceEditCommand
from .plugin.execute_command import LspExecuteCommand
from .plugin.folding_range import LspFoldAllCommand
from .plugin.folding_range import LspFoldCommand
Expand All @@ -68,6 +69,7 @@
from .plugin.panels import LspUpdateLogPanelCommand
from .plugin.panels import LspUpdatePanelCommand
from .plugin.references import LspSymbolReferencesCommand
from .plugin.rename import LspHideRenameButtonsCommand
from .plugin.rename import LspSymbolRenameCommand
from .plugin.save_command import LspSaveAllCommand
from .plugin.save_command import LspSaveCommand
Expand Down
3 changes: 3 additions & 0 deletions plugin/core/panels.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class PanelName:
class PanelManager:
def __init__(self, window: sublime.Window) -> None:
self._window = window
self.rename_panel_buttons = None # type: Optional[sublime.PhantomSet]

def destroy_output_panels(self) -> None:
for field in filter(lambda a: not a.startswith('__'), PanelName.__dict__.keys()):
Expand Down Expand Up @@ -91,6 +92,8 @@ def _create_panel(self, name: str, result_file_regex: str, result_line_regex: st
panel = self.create_output_panel(name)
if not panel:
return None
if name == PanelName.Rename:
self.rename_panel_buttons = sublime.PhantomSet(panel, "lsp_rename_buttons")
settings = panel.settings()
if result_file_regex:
settings.set("result_file_regex", result_file_regex)
Expand Down
7 changes: 4 additions & 3 deletions plugin/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def __str__(self) -> str:
return "invalid URI scheme: {}".format(self.uri)


def get_line(window: sublime.Window, file_name: str, row: int) -> str:
def get_line(window: sublime.Window, file_name: str, row: int, strip: bool = True) -> str:
'''
Get the line from the buffer if the view is open, else get line from linecache.
row - is 0 based. If you want to get the first line, you should pass 0.
Expand All @@ -95,11 +95,12 @@ def get_line(window: sublime.Window, file_name: str, row: int) -> str:
if view:
# get from buffer
point = view.text_point(row, 0)
return view.substr(view.line(point)).strip()
line = view.substr(view.line(point))
else:
# get from linecache
# linecache row is not 0 based, so we increment it by 1 to get the correct line.
return linecache.getline(file_name, row + 1).strip()
line = linecache.getline(file_name, row + 1)
return line.strip() if strip else line


def get_storage_path() -> str:
Expand Down
13 changes: 13 additions & 0 deletions plugin/edit.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from .core.edit import parse_range
from .core.logging import debug
from .core.protocol import TextEdit
from .core.protocol import WorkspaceEdit
from .core.registry import LspWindowCommand
from .core.typing import List, Optional, Any, Generator, Iterable, Tuple
from contextlib import contextmanager
import operator
Expand All @@ -24,6 +27,16 @@ def temporary_setting(settings: sublime.Settings, key: str, val: Any) -> Generat
settings.set(key, prev_val)


class LspApplyWorkspaceEditCommand(LspWindowCommand):

def run(self, session_name: str, edit: WorkspaceEdit) -> None:
session = self.session_by_name(session_name)
if not session:
debug('Could not find session', session_name, 'required to apply WorkspaceEdit')
return
sublime.set_timeout_async(lambda: session.apply_workspace_edit_async(edit))


class LspApplyDocumentEditCommand(sublime_plugin.TextCommand):
re_placeholder = re.compile(r'\$(0|\{0:([^}]*)\})')

Expand Down
168 changes: 149 additions & 19 deletions plugin/rename.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,70 @@
import sublime_plugin


BUTTONS_TEMPLATE = """
<style>
html {{
background-color: transparent;
height: 2rem;
padding-top: 0.3rem;
jwortmann marked this conversation as resolved.
Show resolved Hide resolved
}}
a {{
display: inline;
jwortmann marked this conversation as resolved.
Show resolved Hide resolved
line-height: 1.6rem;
padding-left: 0.6rem;
padding-right: 0.6rem;
border-width: 1px;
border-style: solid;
border-color: #fff4;
border-radius: 4px;
color: #cccccc;
background-color: #3f3f3f;
text-decoration: none;
}}
html.light a {{
border-color: #000a;
color: white;
background-color: #636363;
}}
a.primary {{
background-color: color(var(--accent) min-contrast(white 6.0));
}}
html.light a.primary {{
background-color: color(var(--accent) min-contrast(white 6.0));
}}
jwortmann marked this conversation as resolved.
Show resolved Hide resolved
</style>
<body id='lsp-buttons'>
<a href='{apply}' class='primary'>Apply</a>
<a href='{discard}'>Discard</a>
</body>"""

DISCARD_COMMAND_URL = sublime.command_url('chain', {
'commands': [
['hide_panel', {}],
['lsp_hide_rename_buttons', {}]
]
})


def is_range_response(result: PrepareRenameResult) -> TypeGuard[Range]:
return 'start' in result


def utf16_to_code_points(s: str, col: int) -> int:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe the naming could be a bit more clear that it's about position like utf_16_pos_to_code_point_pos?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really feel utf_16_pos_to_code_point_pos. Could we keep the current name? I have reworded the docstring a little bit, and I think it's clear from the docstring and from usage what the function does.

Btw, the parameter is named col because that's the name given in the View.text_point_utf16 method.

Copy link
Member

@rchl rchl Mar 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name to me strongly suggests something that feels like charset conversion which I think is kinda confusing.

Can we do utf16_offset_to_code_point_offset? Or if we want to match ST more then utf16_point_to_code_point?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm willing to give up if you insist. I'm after all nit picking a bit.

Copy link
Member Author

@jwortmann jwortmann Mar 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I'd say "code points" already implies that it's about the position, because there is no character encoding named "code points". If we want to have the word "position" in the name, then I would suggest utf16_to_utf32_position.

If we want to mach ST closely, then it should probably be code_point_utf16 or even the same name text_point_utf16 (but the latter would be confusing I guess). The only difference between the builtin and this function here is that the builtin counts from the beginning of the view's content, while this one takes the string as an explicit parameter, and it always uses clamp=True.

So I would prefer one of these:

  • utf16_to_code_points (current)
  • utf16_to_utf32_position
  • code_point_utf16

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kinda like utf16_to_utf32_position but choose yourself whether to rename it or not later

"""Convert a position from UTF-16 code units to Unicode code points, usable for string slicing."""
utf16_len = 0
idx = 0
for idx, c in enumerate(s):
if utf16_len >= col:
if utf16_len > col: # If col is in the middle of a character (emoji), don't advance to the next code point
idx -= 1
break
utf16_len += 1 if ord(c) < 65536 else 2
else:
idx += 1 # get_line function trims the trailing '\n'
return idx


# The flow of this command is fairly complicated so it deserves some documentation.
#
# When "LSP: Rename" is triggered from the Command Palette, the flow can go one of two ways:
Expand Down Expand Up @@ -134,17 +194,17 @@ def _on_rename_result_async(self, session: Session, response: Optional[Workspace
if not response:
return session.window.status_message('Nothing to rename')
changes = parse_workspace_edit(response)
count = len(changes.keys())
if count == 1:
file_count = len(changes.keys())
if file_count == 1:
session.apply_parsed_workspace_edits(changes)
return
total_changes = sum(map(len, changes.values()))
message = "Replace {} occurrences across {} files?".format(total_changes, count)
choice = sublime.yes_no_cancel_dialog(message, "Replace", "Dry Run")
message = "Replace {} occurrences across {} files?".format(total_changes, file_count)
choice = sublime.yes_no_cancel_dialog(message, "Replace", "Preview", title="Rename")
if choice == sublime.DIALOG_YES:
session.apply_parsed_workspace_edits(changes)
elif choice == sublime.DIALOG_NO:
self._render_rename_panel(changes, total_changes, count)
self._render_rename_panel(response, changes, total_changes, file_count, session.config.name)

def _on_prepare_result(self, pos: int, response: Optional[PrepareRenameResult]) -> None:
if response is None:
Expand Down Expand Up @@ -172,39 +232,95 @@ def _get_relative_path(self, file_path: str) -> str:
base_dir = wm.get_project_path(file_path)
return os.path.relpath(file_path, base_dir) if base_dir else file_path

def _render_rename_panel(self, changes_per_uri: WorkspaceChanges, total_changes: int, file_count: int) -> None:
def _render_rename_panel(
self,
workspace_edit: WorkspaceEdit,
changes_per_uri: WorkspaceChanges,
total_changes: int,
file_count: int,
session_name: str
) -> None:
wm = windows.lookup(self.view.window())
if not wm:
return
panel = wm.panel_manager and wm.panel_manager.ensure_rename_panel()
pm = wm.panel_manager
if not pm:
return
panel = pm.ensure_rename_panel()
if not panel:
return
to_render = [] # type: List[str]
reference_document = [] # type: List[str]
header_lines = "{} changes across {} files.\n\n".format(total_changes, file_count)
to_render.append(header_lines)
reference_document.append(header_lines)
ROWCOL_PREFIX = " {:>4}:{:<4} {}"
for uri, (changes, _) in changes_per_uri.items():
scheme, file = parse_uri(uri)
if scheme == "file":
to_render.append('{}:'.format(self._get_relative_path(file)))
else:
to_render.append('{}:'.format(uri))
filename_line = '{}:'.format(self._get_relative_path(file) if scheme == 'file' else uri)
to_render.append(filename_line)
reference_document.append(filename_line)
for edit in changes:
start = parse_range(edit['range']['start'])
if scheme == "file":
line_content = get_line(wm.window, file, start[0])
start_row, start_col_utf16 = parse_range(edit['range']['start'])
line_content = get_line(wm.window, file, start_row, strip=False) if scheme == 'file' else \
'<no preview available>'
start_col = utf16_to_code_points(line_content, start_col_utf16)
rchl marked this conversation as resolved.
Show resolved Hide resolved
original_line = ROWCOL_PREFIX.format(start_row + 1, start_col + 1, line_content.strip() + "\n")
reference_document.append(original_line)
if scheme == "file" and line_content:
end_row, end_col_utf16 = parse_range(edit['range']['end'])
new_text_rows = edit['newText'].split('\n')
new_line_content = line_content[:start_col] + new_text_rows[0]
if start_row == end_row and len(new_text_rows) == 1:
end_col = start_col if end_col_utf16 <= start_col_utf16 else \
utf16_to_code_points(line_content, end_col_utf16)
if end_col < len(line_content):
new_line_content += line_content[end_col:]
to_render.append(
ROWCOL_PREFIX.format(start_row + 1, start_col + 1, new_line_content.strip() + "\n"))
else:
line_content = '<no preview available>'
to_render.append(" {:>4}:{:<4} {}".format(start[0] + 1, start[1] + 1, line_content))
to_render.append("") # this adds a spacing between filenames
to_render.append(original_line)
characters = "\n".join(to_render)
base_dir = wm.get_project_path(self.view.file_name() or "")
panel.settings().set("result_base_dir", base_dir)
panel.run_command("lsp_clear_panel")
wm.window.run_command("show_panel", {"panel": "output.rename"})
fmt = "{} changes across {} files.\n\n{}"
panel.run_command('append', {
'characters': fmt.format(total_changes, file_count, characters),
'characters': characters,
'force': True,
'scroll_to_end': False
})
panel.set_reference_document("\n".join(reference_document))
selection = panel.sel()
selection.add(sublime.Region(0, panel.size()))
panel.run_command('toggle_inline_diff')
selection.clear()
buttons = pm.rename_panel_buttons
rchl marked this conversation as resolved.
Show resolved Hide resolved
if not buttons:
return
buttons_position = sublime.Region(len(to_render[0]) - 1)
BUTTONS_HTML = BUTTONS_TEMPLATE.format(
apply=sublime.command_url('chain', {
'commands': [
[
'lsp_apply_workspace_edit',
{'session_name': session_name, 'edit': workspace_edit}
],
[
'hide_panel',
{}
],
[
'lsp_hide_rename_buttons',
{}
]
]
}),
discard=DISCARD_COMMAND_URL
)
buttons.update([
sublime.Phantom(buttons_position, BUTTONS_HTML, sublime.LAYOUT_BLOCK)
])


class RenameSymbolInputHandler(sublime_plugin.TextInputHandler):
Expand All @@ -226,3 +342,17 @@ def initial_text(self) -> str:

def validate(self, name: str) -> bool:
return len(name) > 0


class LspHideRenameButtonsCommand(sublime_plugin.WindowCommand):

def run(self) -> None:
wm = windows.lookup(self.window)
if not wm:
return
pm = wm.panel_manager
if not pm:
return
buttons = pm.rename_panel_buttons
if buttons:
buttons.update([])
rchl marked this conversation as resolved.
Show resolved Hide resolved
50 changes: 50 additions & 0 deletions tests/test_rename_panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from LSP.plugin.rename import utf16_to_code_points
import unittest


class LspRenamePanelTests(unittest.TestCase):

def test_utf16_ascii(self):
s = 'abc'
self.assertEqual(utf16_to_code_points(s, 0), 0)
self.assertEqual(utf16_to_code_points(s, 1), 1)
self.assertEqual(utf16_to_code_points(s, 2), 2)
self.assertEqual(utf16_to_code_points(s, 3), 3) # EOL after last character should count as its own code point
self.assertEqual(utf16_to_code_points(s, 1337), 3) # clamp to EOL

def test_utf16_deseret_letter(self):
# https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocuments
s = 'a𐐀b'
self.assertEqual(len(s), 3)
self.assertEqual(utf16_to_code_points(s, 0), 0)
self.assertEqual(utf16_to_code_points(s, 1), 1)
self.assertEqual(utf16_to_code_points(s, 2), 1) # 𐐀 needs 2 UTF-16 code units, so this is still at code point 1
self.assertEqual(utf16_to_code_points(s, 3), 2)
self.assertEqual(utf16_to_code_points(s, 4), 3)
self.assertEqual(utf16_to_code_points(s, 1337), 3)

def test_utf16_emoji(self):
s = 'a😀x'
self.assertEqual(len(s), 3)
self.assertEqual(utf16_to_code_points(s, 0), 0)
self.assertEqual(utf16_to_code_points(s, 1), 1)
self.assertEqual(utf16_to_code_points(s, 2), 1)
self.assertEqual(utf16_to_code_points(s, 3), 2)
self.assertEqual(utf16_to_code_points(s, 4), 3)
self.assertEqual(utf16_to_code_points(s, 1337), 3)

def test_utf16_emoji_zwj_sequence(self):
# https://unicode.org/emoji/charts/emoji-zwj-sequences.html
s = 'a😵‍💫x'
self.assertEqual(len(s), 5)
self.assertEqual(s, 'a\U0001f635\u200d\U0001f4abx')
# 😵‍💫 consists of 5 UTF-16 code units and Python treats it as 3 characters
self.assertEqual(utf16_to_code_points(s, 0), 0) # a
self.assertEqual(utf16_to_code_points(s, 1), 1) # \U0001f635
self.assertEqual(utf16_to_code_points(s, 2), 1) # \U0001f635
self.assertEqual(utf16_to_code_points(s, 3), 2) # \u200d (zero width joiner)
self.assertEqual(utf16_to_code_points(s, 4), 3) # \U0001f4ab
self.assertEqual(utf16_to_code_points(s, 5), 3) # \U0001f4ab
self.assertEqual(utf16_to_code_points(s, 6), 4) # x
self.assertEqual(utf16_to_code_points(s, 7), 5) # after x
self.assertEqual(utf16_to_code_points(s, 1337), 5)