diff --git a/boot.py b/boot.py index 56ae078db..c4a791485 100644 --- a/boot.py +++ b/boot.py @@ -69,6 +69,8 @@ from .plugin.references import LspSymbolReferencesCommand from .plugin.rename import LspHideRenameButtonsCommand from .plugin.rename import LspSymbolRenameCommand +from .plugin.rename_file import LspRenameFileCommand +from .plugin.rename_file import RenameFileCommand from .plugin.save_command import LspSaveAllCommand from .plugin.save_command import LspSaveCommand from .plugin.selection_range import LspExpandSelectionCommand @@ -146,6 +148,8 @@ "LspSymbolImplementationCommand", "LspSymbolReferencesCommand", "LspSymbolRenameCommand", + "LspRenameFileCommand", + "RenameFileCommand", "LspSymbolTypeDefinitionCommand", "LspToggleCodeLensesCommand", "LspToggleHoverPopupsCommand", @@ -261,6 +265,10 @@ def on_pre_close(self, view: sublime.View) -> None: tup[1](None) break + def on_window_command(self, window: sublime.Window, command_name: str, args: dict) -> tuple[str, dict] | None: + if command_name == "rename_path": + return ("rename_file", args) + def on_post_window_command(self, window: sublime.Window, command_name: str, args: dict[str, Any] | None) -> None: if command_name == "show_panel": wm = windows.lookup(window) diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py index 69a440f72..f47b62907 100644 --- a/plugin/core/protocol.py +++ b/plugin/core/protocol.py @@ -6009,6 +6009,10 @@ def colorPresentation(cls, params: ColorPresentationParams, view: sublime.View) def willSaveWaitUntil(cls, params: WillSaveTextDocumentParams, view: sublime.View) -> Request: return Request("textDocument/willSaveWaitUntil", params, view) + @classmethod + def willRenameFiles(cls, params: RenameFilesParams) -> Request: + return Request("workspace/willRenameFiles", params) + @classmethod def documentSymbols(cls, params: DocumentSymbolParams, view: sublime.View) -> Request: return Request("textDocument/documentSymbol", params, view, progress=True) @@ -6201,6 +6205,10 @@ def didSave(cls, params: DidSaveTextDocumentParams) -> Notification: def didClose(cls, params: DidCloseTextDocumentParams) -> Notification: return Notification("textDocument/didClose", params) + @classmethod + def didRenameFiles(cls, params: RenameFilesParams) -> Notification: + return Notification("workspace/didRenameFiles", params) + @classmethod def didChangeConfiguration(cls, params: DidChangeConfigurationParams) -> Notification: return Notification("workspace/didChangeConfiguration", params) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index ccb30b429..04e7fa41f 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -493,6 +493,11 @@ def get_initialize_params(variables: dict[str, str], workspace_folders: list[Wor "codeLens": { "refreshSupport": True }, + "fileOperations": { + "dynamicRegistration": True, + "willRename": True, + "didRename": True + }, "inlayHint": { "refreshSupport": True }, diff --git a/plugin/core/types.py b/plugin/core/types.py index 46d7efa7b..e35bb76f5 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -2,6 +2,9 @@ from .collections import DottedDict from .file_watcher import FileWatcherEventType from .logging import debug, set_debug_logging +from .protocol import FileOperationFilter +from .protocol import FileOperationPatternKind +from .protocol import FileOperationRegistrationOptions from .protocol import TextDocumentSyncKind from .url import filename_to_uri from .url import parse_uri @@ -10,6 +13,7 @@ from wcmatch.glob import BRACE from wcmatch.glob import globmatch from wcmatch.glob import GLOBSTAR +from wcmatch.glob import IGNORECASE import contextlib import fnmatch import os @@ -440,6 +444,34 @@ def matches(self, view: sublime.View) -> bool: return any(f(view) for f in self.filters) if self.filters else True +def match_file_operation_filters( + file_operation_options: FileOperationRegistrationOptions, path: str, view: sublime.View | None +) -> bool: + def matches(file_operation_filter: FileOperationFilter) -> bool: + pattern = file_operation_filter.get('pattern') + scheme = file_operation_filter.get('scheme') + if scheme and view: + uri = view.settings().get("lsp_uri") + if isinstance(uri, str) and parse_uri(uri)[0] != scheme: + return False + if pattern: + matches = pattern.get('matches') + if matches == FileOperationPatternKind.File and os.path.isdir(path): + return False + if matches == FileOperationPatternKind.Folder and os.path.isfile(path): + return False + options = pattern.get('options', {}) + flags = GLOBSTAR | BRACE + if options.get('ignoreCase', False): + flags |= IGNORECASE + if not globmatch(path, pattern['glob'], flags=flags): + return False + return True + + filters = file_operation_options.get('filters') + return any(matches(_filter) for _filter in filters) if filters else True + + # method -> (capability dotted path, optional registration dotted path) # these are the EXCEPTIONS. The general rule is: method foo/bar --> (barProvider, barProvider.id) _METHOD_TO_CAPABILITY_EXCEPTIONS: dict[str, tuple[str, str | None]] = { diff --git a/plugin/rename_file.py b/plugin/rename_file.py new file mode 100644 index 000000000..a73447207 --- /dev/null +++ b/plugin/rename_file.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from .core.types import match_file_operation_filters +from .core.open import open_file_uri +from .core.protocol import Notification, RenameFilesParams, Request, WorkspaceEdit +from .core.registry import LspWindowCommand +from pathlib import Path +from urllib.parse import urljoin +import os +import sublime +import functools + + +# It is bad that this command is named RenameFileCommand, same as the command in Default/rename.py +# ST has a bug that prevents the RenameFileCommand to be override in on_window_command: +# https://github.com/sublimehq/sublime_text/issues/2234 +# So naming this command "RenameFileCommand" is one BAD way to override the rename behavior. +class RenameFileCommand(LspWindowCommand): + def is_enabled(self): + return True + + def run(self, paths: list[str] | None = None) -> None: + old_path = paths[0] if paths else None + path_name = Path(old_path or "").name + view = self.window.active_view() + if path_name == "" and view: + path_name = Path(view.file_name() or "").name + v = self.window.show_input_panel( + "(LSP) New Name:", + path_name, + functools.partial(self.on_done, old_path), + None, + None) + v.sel().clear() + name, _ext = os.path.splitext(path_name) + v.sel().add(sublime.Region(0, len(name))) + + def on_done(self, old_path: str | None, new_name: str) -> None: + if new_name: + self.window.run_command('lsp_rename_file', { + "new_name": new_name, + "old_path": old_path + }) + + +class LspRenameFileCommand(LspWindowCommand): + capability = 'workspace.fileOperations.willRename' + + def is_enabled(self): + return True + + def want_event(self) -> bool: + return False + + def run( + self, + new_name: str, # new_name can be: FILE_NAME.xy OR ./FILE_NAME.xy OR ../../FILE_NAME.xy + old_path: str | None = None, + ) -> None: + session = self.session() + view = self.window.active_view() + old_path = old_path or view.file_name() if view else None + if old_path is None: # handle renaming buffers + if view: + view.set_name(new_name) + return + new_path = os.path.normpath(Path(old_path).parent / new_name) + if os.path.exists(new_path): + self.window.status_message('Unable to Rename. Already exists') + return + rename_file_params: RenameFilesParams = { + "files": [{ + "newUri": urljoin("file:", new_path), + "oldUri": urljoin("file:", old_path), + }] + } + file_operation_options = session.get_capability('workspace.fileOperations.willRename') if session else None + if session and file_operation_options and match_file_operation_filters(file_operation_options, old_path, view): + request = Request.willRenameFiles(rename_file_params) + session.send_request( + request, + lambda res: self.handle(res, session.config.name, old_path, new_path, rename_file_params, view) + ) + else: + self.rename_path(old_path, new_path) + self.notify_did_rename(rename_file_params, new_path, view) + + def handle(self, res: WorkspaceEdit | None, session_name: str, + old_path: str, new_path: str, rename_file_params: RenameFilesParams, view: sublime.View | None) -> None: + if session := self.session_by_name(session_name): + # LSP spec - Apply WorkspaceEdit before the files are renamed + if res: + session.apply_workspace_edit_async(res, is_refactoring=True) + self.rename_path(old_path, new_path) + self.notify_did_rename(rename_file_params, new_path, view) + + def rename_path(self, old_path: str, new_path: str) -> None: + old_regions: list[sublime.Region] = [] + if view := self.window.find_open_file(old_path): + old_regions = [region for region in view.sel()] + view.close() # LSP spec - send didClose for the old file + new_dir = Path(new_path).parent + if not os.path.exists(new_dir): + os.makedirs(new_dir) + isdir = os.path.isdir(old_path) + try: + os.rename(old_path, new_path) + except Exception: + sublime.status_message("Unable to rename") + if isdir: + for v in self.window.views(): + file_name = v.file_name() + if file_name and file_name.startswith(old_path): + v.retarget(file_name.replace(old_path, new_path)) + if os.path.isfile(new_path): + def restore_regions(v: sublime.View | None) -> None: + if not v: + return + v.sel().clear() + v.sel().add_all(old_regions) + + # LSP spec - send didOpen for the new file + open_file_uri(self.window, new_path).then(restore_regions) + + def notify_did_rename(self, rename_file_params: RenameFilesParams, path: str, view: sublime.View | None): + for s in self.sessions(): + file_operation_options = s.get_capability('workspace.fileOperations.didRename') + if file_operation_options and match_file_operation_filters(file_operation_options, path, view): + s.send_notification(Notification.didRenameFiles(rename_file_params)) diff --git a/stubs/wcmatch/glob.pyi b/stubs/wcmatch/glob.pyi index 9d5a7a6f6..0cc199577 100644 --- a/stubs/wcmatch/glob.pyi +++ b/stubs/wcmatch/glob.pyi @@ -2,6 +2,7 @@ from typing import Any, Optional BRACE: int = ... GLOBSTAR: int = ... +IGNORECASE: int = ... def globmatch(