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

Fix prepareRename support #2127

Merged
merged 4 commits into from
Dec 11, 2022
Merged
Show file tree
Hide file tree
Changes from 2 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
8 changes: 7 additions & 1 deletion plugin/core/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from .protocol import LSPObject
from .protocol import MarkupKind
from .protocol import Notification
from .protocol import PrepareSupportDefaultBehavior
from .protocol import PublishDiagnosticsParams
from .protocol import Range
from .protocol import Request
Expand Down Expand Up @@ -345,7 +346,8 @@ def get_initialize_params(variables: Dict[str, str], workspace_folders: List[Wor
},
"rename": {
"dynamicRegistration": True,
"prepareSupport": True
"prepareSupport": True,
"prepareSupportDefaultBehavior": PrepareSupportDefaultBehavior.Identifier,
},
"colorProvider": {
"dynamicRegistration": True # exceptional
Expand Down Expand Up @@ -600,6 +602,10 @@ def sessions_async(self, capability_path: Optional[str] = None) -> Generator['Se
def session_views_async(self) -> Iterable['SessionViewProtocol']:
raise NotImplementedError()

@abstractmethod
def purge_changes_async(self) -> None:
raise NotImplementedError()

@abstractmethod
def on_session_initialized_async(self, session: 'Session') -> None:
raise NotImplementedError()
Expand Down
1 change: 1 addition & 0 deletions plugin/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,7 @@ def has_capability_async(self, session: Session, capability_path: str) -> bool:
def purge_changes_async(self) -> None:
for sv in self.session_views_async():
sv.purge_changes_async()
break
rchl marked this conversation as resolved.
Show resolved Hide resolved

def trigger_on_pre_save_async(self) -> None:
for sv in self.session_views_async():
Expand Down
186 changes: 104 additions & 82 deletions plugin/rename.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,60 @@
from .core.edit import parse_workspace_edit
from .core.edit import TextEditTuple
from .core.protocol import PrepareRenameResult
from .core.protocol import Range
from .core.protocol import Request
from .core.registry import get_position
from .core.registry import LspTextCommand
from .core.registry import windows
from .core.sessions import Session
from .core.typing import Any, Optional, Dict, List
from .core.typing import Any, Optional, Dict, List, TypeGuard
from .core.url import parse_uri
from .core.views import first_selection_region
from .core.views import get_line
from .core.views import range_to_region
from .core.views import text_document_position_params
import functools
from functools import partial
import os
import sublime
import sublime_plugin


class RenameSymbolInputHandler(sublime_plugin.TextInputHandler):
def want_event(self) -> bool:
return False

def __init__(self, view: sublime.View, placeholder: str) -> None:
self.view = view
self._placeholder = placeholder

def name(self) -> str:
return "new_name"

def placeholder(self) -> str:
return self._placeholder

def initial_text(self) -> str:
return self.placeholder()

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

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


# 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:
#
# 1. Session doesn't have support for "prepareProvider":
# - input() gets called with empty "args" - returns an instance of "RenameSymbolInputHandler"
# - input overlay triggered
# - user enters new name and confirms
# - run() gets called with "new_name" argument
# - rename is performed
#
# 2. Session has support for "prepareProvider":
# - input() gets called with empty "args" - returns None
# - run() gets called with no arguments
# - "prepare" request is triggered on the session
# - based on the "prepare" response, the "placeholder" value is computed
# - "lsp_symbol_rename" command is re-triggered with computed "placeholder" argument
# - run() gets called with "placeholder" argument set
# - run() manually throws a TypeError
# - input() gets called with "placeholder" argument set - returns an instance of "RenameSymbolInputHandler"
# - input overlay triggered
# - user enters new name and confirms
# - run() gets called with "new_name" argument
# - rename is performed
#
# Note how triggering the command programmatically triggers run() first while when triggering the command from
# the Command Palette the input() gets called first.

class LspSymbolRenameCommand(LspTextCommand):

capability = 'renameProvider'

# mypy: Signature of "is_enabled" incompatible with supertype "LspTextCommand"
def is_enabled( # type: ignore
self,
new_name: str = "",
placeholder: str = "",
position: Optional[int] = None,
event: Optional[dict] = None,
point: Optional[int] = None
) -> bool:
if self.best_session("renameProvider.prepareProvider"):
# The language server will tell us if the selection is on a valid token.
return True
return super().is_enabled(event, point)
Comment on lines -45 to -57
Copy link
Member Author

Choose a reason for hiding this comment

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

This was redundant and it's enough to use base implementation.
When this code was introduced it looked a bit differently and it made sense then.


def is_visible(
self,
new_name: str = "",
Expand All @@ -65,24 +64,28 @@ def is_visible(
point: Optional[int] = None
) -> bool:
if self.applies_to_context_menu(event):
return self.is_enabled(new_name, placeholder, position, event, point)
return self.is_enabled(event, point)
return True

def input(self, args: dict) -> Optional[sublime_plugin.TextInputHandler]:
if "new_name" not in args:
placeholder = args.get("placeholder", "")
if not placeholder:
point = args.get("point")
# guess the symbol name
if not isinstance(point, int):
region = first_selection_region(self.view)
if region is None:
return None
point = region.b
placeholder = self.view.substr(self.view.word(point))
return RenameSymbolInputHandler(self.view, placeholder)
else:
if "new_name" in args:
# Defer to "run" and trigger rename.
return None
prepare_provider_session = self.best_session("renameProvider.prepareProvider")
if prepare_provider_session and "placeholder" not in args:
# Defer to "run" and trigger "prepare" request.
return None
placeholder = args.get("placeholder", "")
if not placeholder:
point = args.get("point")
# guess the symbol name
if not isinstance(point, int):
region = first_selection_region(self.view)
if region is None:
return None
point = region.b
placeholder = self.view.substr(self.view.word(point))
return RenameSymbolInputHandler(self.view, placeholder)

def run(
self,
Expand All @@ -93,29 +96,26 @@ def run(
event: Optional[dict] = None,
point: Optional[int] = None
) -> None:
if position is None:
tmp_pos = get_position(self.view, event, point)
if tmp_pos is None:
listener = self.get_listener()
if listener:
listener.purge_changes_async()
Comment on lines +99 to +101
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've added "purge" because I've noticed an issue with lsp-typescript where it requests client to apply edits and immediately triggers rename before client has triggered "didChange".

https://github.com/typescript-language-server/typescript-language-server/blob/0403b36a6ae181fb25f8236f47029afeb2a739fb/src/lsp-server.ts#L938-L949

location = self._get_location(position, event, point)
prepare_provider_session = self.best_session("renameProvider.prepareProvider")
if new_name or placeholder or not prepare_provider_session:
if location and new_name:
self._do_rename(location, new_name)
return
pos = tmp_pos
if new_name:
return self._do_rename(pos, new_name)
else:
session = self.best_session("{}.prepareProvider".format(self.capability))
if session:
params = text_document_position_params(self.view, pos)
request = Request("textDocument/prepareRename", params, self.view, progress=True)
self.event = event
session.send_request(request, lambda r: self.on_prepare_result(r, pos), self.on_prepare_error)
else:
# trigger InputHandler manually
raise TypeError("required positional argument")
else:
if new_name:
return self._do_rename(position, new_name)
else:
# trigger InputHandler manually
raise TypeError("required positional argument")
# Trigger InputHandler manually.
raise TypeError("required positional argument")
if not location:
return
params = text_document_position_params(self.view, location)
request = Request("textDocument/prepareRename", params, self.view, progress=True)
prepare_provider_session.send_request(
request, partial(self._on_prepare_result, location), self._on_prepare_error)

def _get_location(self, position: Optional[int], event: Optional[dict], point: Optional[int]) -> Optional[int]:
return position or get_position(self.view, event, point)

def _do_rename(self, position: int, new_name: str) -> None:
session = self.best_session(self.capability)
Expand All @@ -128,7 +128,7 @@ def _do_rename(self, position: int, new_name: str) -> None:
"newName": new_name,
}
request = Request("textDocument/rename", params, self.view, progress=True)
session.send_request(request, functools.partial(self._on_rename_result_async, session))
session.send_request(request, partial(self._on_rename_result_async, session))

def _on_rename_result_async(self, session: Session, response: Any) -> None:
if not response:
Expand All @@ -146,22 +146,23 @@ def _on_rename_result_async(self, session: Session, response: Any) -> None:
elif choice == sublime.DIALOG_NO:
self._render_rename_panel(changes, total_changes, count)

def on_prepare_result(self, response: Any, pos: int) -> None:
def _on_prepare_result(self, pos: int, response: Optional[PrepareRenameResult]) -> None:
if response is None:
sublime.error_message("The current selection cannot be renamed")
return
# It must be a dict at this point.
if "placeholder" in response:
placeholder = response["placeholder"]
r = response["range"]
if is_range_response(response):
r = range_to_region(response, self.view)
placeholder = self.view.substr(r)
pos = r.a
elif "placeholder" in response:
placeholder = response["placeholder"] # type: ignore
pos = range_to_region(response["range"], self.view).a # type: ignore
else:
placeholder = self.view.substr(self.view.word(pos))
r = response
region = range_to_region(r, self.view)
args = {"placeholder": placeholder, "position": region.a, "event": self.event}
args = {"placeholder": placeholder, "position": pos}
self.view.run_command("lsp_symbol_rename", args)

def on_prepare_error(self, error: Any) -> None:
def _on_prepare_error(self, error: Any) -> None:
sublime.error_message("Rename error: {}".format(error["message"]))

def _get_relative_path(self, file_path: str) -> str:
Expand Down Expand Up @@ -209,3 +210,24 @@ def _render_rename_panel(
'force': True,
'scroll_to_end': False
})


class RenameSymbolInputHandler(sublime_plugin.TextInputHandler):
def want_event(self) -> bool:
return False

def __init__(self, view: sublime.View, placeholder: str) -> None:
self.view = view
self._placeholder = placeholder

def name(self) -> str:
return "new_name"

def placeholder(self) -> str:
return self._placeholder

def initial_text(self) -> str:
return self.placeholder()

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