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

Add willRename and didRename fileOperations #2498

Closed
wants to merge 39 commits into from
Closed
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
f5ec6c5
add willRename and didRename fileOperations
predragnikolic Jun 25, 2024
e26a48e
always enable
predragnikolic Jun 25, 2024
e6304c2
add renaming from the sidebar
predragnikolic Jun 25, 2024
e5e0722
changes
predragnikolic Jun 25, 2024
5c91301
fix styles
predragnikolic Jun 25, 2024
075e50e
add new line
predragnikolic Jun 25, 2024
814b0bb
hande if new path directory doesn't exist
predragnikolic Jun 25, 2024
fb42ffc
Merge branch 'main' into add-will-rename-and-did-rename
predragnikolic Jun 25, 2024
d488b06
handle renaming buffers
predragnikolic Jun 26, 2024
d1a10cf
ahh...
predragnikolic Jun 26, 2024
08e3af1
add initial_selection
predragnikolic Jun 26, 2024
6f65462
improve initial_selection and rename file_name to path
predragnikolic Jun 26, 2024
c9e8772
Split "Lsp: Rename" to "Lsp: Rename Folder" and "Lsp: Rename File"
predragnikolic Jun 27, 2024
e0c9c82
new line
predragnikolic Jun 27, 2024
a52b5c4
when renaming a directory, it would be good to retarget all open view…
predragnikolic Jun 27, 2024
2acceb6
save some lines
predragnikolic Jun 27, 2024
9ab9dc5
remove more lines
predragnikolic Jun 27, 2024
f5c26a6
few less lines
predragnikolic Jun 27, 2024
437659f
simpler conditions
predragnikolic Jun 27, 2024
3647f92
avoid multiple statements on one line
predragnikolic Jun 27, 2024
3389f66
implement FileOperationFilter
predragnikolic Jul 2, 2024
4785d49
remomve few lines
predragnikolic Jul 2, 2024
2f5af31
fix flake
predragnikolic Jul 2, 2024
890667c
fix pyright
predragnikolic Jul 2, 2024
af04c56
remove is_visible code
predragnikolic Jul 2, 2024
178adae
remove LSP: Rename File and Rename folder in favor of LSP: Rename... …
predragnikolic Jul 2, 2024
8ef443c
rename LspRenameFileCommand to LspRenamePathCommand
predragnikolic Jul 2, 2024
d1342b9
remove FileOperationFilterChecker, FileOperationFilterMatcher in favo…
predragnikolic Jul 2, 2024
057eaff
flake8 fixes
predragnikolic Jul 2, 2024
cb82086
cannot use an input handler to rename folder because it always displa…
predragnikolic Jul 2, 2024
728c6ea
Update plugin/core/types.py
predragnikolic Jul 3, 2024
93b024a
remove unnecessary if
predragnikolic Jul 3, 2024
1ff4648
remove LSP: Rename... from sidebar in favor of overriding the existin…
predragnikolic Jul 3, 2024
1ae7ec4
always enable LspRenamePathCommand
predragnikolic Jul 3, 2024
6181644
handle OS errors
predragnikolic Jul 3, 2024
181f17c
except Exception
predragnikolic Jul 4, 2024
18e8549
Remove "LSP: Rename File" commands, instead override Default ST commands
predragnikolic Aug 18, 2024
51962f1
remove unused import
predragnikolic Aug 18, 2024
21f1bf7
Merge branch 'main' into add-will-rename-and-did-rename
predragnikolic Aug 20, 2024
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
4 changes: 4 additions & 0 deletions Default.sublime-commands
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@
"caption": "LSP: Rename",
"command": "lsp_symbol_rename"
},
{
"caption": "LSP: Rename File",
"command": "lsp_rename_path"
jwortmann marked this conversation as resolved.
Show resolved Hide resolved
},
{
"caption": "LSP: Code Action",
"command": "lsp_code_actions"
Expand Down
8 changes: 8 additions & 0 deletions Side Bar.sublime-menu
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[
jwortmann marked this conversation as resolved.
Show resolved Hide resolved
{
"caption": "LSP: Rename...",
"mnemonic": "l",
"command": "lsp_rename_path",
"args": {"paths": []}
}
]
2 changes: 2 additions & 0 deletions boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
from .plugin.references import LspSymbolReferencesCommand
from .plugin.rename import LspHideRenameButtonsCommand
from .plugin.rename import LspSymbolRenameCommand
from .plugin.rename_file import LspRenamePathCommand
from .plugin.save_command import LspSaveAllCommand
from .plugin.save_command import LspSaveCommand
from .plugin.selection_range import LspExpandSelectionCommand
Expand Down Expand Up @@ -146,6 +147,7 @@
"LspSymbolImplementationCommand",
"LspSymbolReferencesCommand",
"LspSymbolRenameCommand",
"LspRenamePathCommand",
"LspSymbolTypeDefinitionCommand",
"LspToggleCodeLensesCommand",
"LspToggleHoverPopupsCommand",
Expand Down
8 changes: 8 additions & 0 deletions plugin/core/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -6064,6 +6064,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)
Expand Down Expand Up @@ -6256,6 +6260,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)
Expand Down
5 changes: 5 additions & 0 deletions plugin/core/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
67 changes: 66 additions & 1 deletion plugin/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
from .collections import DottedDict
from .file_watcher import FileWatcherEventType
from .logging import debug, set_debug_logging
from .protocol import TextDocumentSyncKind
from .protocol import FileOperationFilter, FileOperationPattern, FileOperationPatternKind, TextDocumentSyncKind
from .url import filename_to_uri
from .url import parse_uri
from typing import Any, Callable, Dict, Generator, Iterable, List, Optional, TypedDict, TypeVar, Union
from typing import cast
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
Expand Down Expand Up @@ -440,6 +441,70 @@ def matches(self, view: sublime.View) -> bool:
return any(f(view) for f in self.filters) if self.filters else True


class FileOperationFilterChecker:
"""
A file operation filter denotes a view or path through properties like scheme or pattern. An example is a filter
that applies to TypeScript files on disk. Another example is a filter that applies to JSON files with name
package.json:
{
"scheme": "file",
"pattern": {
"glob": "**/*.{ts,js,jsx,tsx,mjs,mts,cjs,cts}",
"matches": "file"
}
}
"""

__slots__ = ("scheme", "pattern")

def __init__(
self,
scheme: str | None = None,
pattern: FileOperationPattern | None = None
) -> None:
self.scheme = scheme
self.pattern = pattern

def __call__(self, path: str, view: sublime.View | None) -> bool:
if self.scheme and view:
uri = view.settings().get("lsp_uri")
if isinstance(uri, str) and parse_uri(uri)[0] != self.scheme:
return False
if self.pattern:
matches = self.pattern.get('matches')
if matches:
jwortmann marked this conversation as resolved.
Show resolved Hide resolved
if matches == FileOperationPatternKind.File and os.path.isdir(path):
return False
if matches == FileOperationPatternKind.Folder and os.path.isfile(path):
return False
options = self.pattern.get('options', {})
flags = GLOBSTAR | BRACE
if options.get('ignoreCase', False):
flags |= IGNORECASE
if not globmatch(path, self.pattern['glob'], flags=flags):
return False
return True


class FileOperationFilterMatcher:
"""
A FileOperationFilterMatcher is a list of FileOperationFilterChecker.
Provides logic to see if a path/view matches the specified FileOperationFilter's.
"""

__slots__ = ("filters",)

def __init__(self, file_filters: list[FileOperationFilter]) -> None:
self.filters = [FileOperationFilterChecker(**file_filter) for file_filter in file_filters]

def __bool__(self) -> bool:
return bool(self.filters)

def matches(self, new_path: str, view: sublime.View | None) -> bool:
"""Does this selector match the view? A selector with no filters matches all views."""
return any(f(new_path, view) for f in self.filters) if self.filters else True
rchl marked this conversation as resolved.
Show resolved Hide resolved


# 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]] = {
Expand Down
133 changes: 133 additions & 0 deletions plugin/rename_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
from __future__ import annotations

from .core.types import FileOperationFilterMatcher
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 sublime_plugin


class RenameFileInputHandler(sublime_plugin.TextInputHandler):
def __init__(self, path: str) -> None:
self.path = path

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

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

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

def initial_selection(self) -> list[tuple[int, int]]:
name, _ext = os.path.splitext(self.path)
return [(0, len(name))]

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


class LspRenamePathCommand(LspWindowCommand):
capability = 'workspace.fileOperations.willRename'

def is_enabled(self):
return True

def want_event(self) -> bool:
return False

def input(self, args: dict) -> sublime_plugin.TextInputHandler | None:
if "new_name" in args:
return None
old_path = self.get_old_path(args.get('paths'), self.window.active_view())
return RenameFileInputHandler(Path(old_path or "").name)

def run(
self,
new_name: str, # new_name can be: FILE_NAME.xy OR ./FILE_NAME.xy OR ../../FILE_NAME.xy
paths: list[str] | None = None, # exist when invoked from the sidebar with "LSP: Rename..."
) -> None:
session = self.session()
view = self.window.active_view()
old_path = self.get_old_path(paths, view)
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),
}]
}
if not session:
self.rename_path(old_path, new_path)
self.notify_did_rename(rename_file_params, new_path, view)
return
filters = (session.get_capability('workspace.fileOperations.willRename') or {}).get('filters')
if filters and FileOperationFilterMatcher(filters).matches(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 get_old_path(self, paths: list[str] | None, view: sublime.View | None) -> str | None:
if paths:
return paths[0]
if view:
return view.file_name()

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)
os.rename(old_path, new_path)
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():
filters = (s.get_capability('workspace.fileOperations.didRename') or {}).get('filters')
if not filters:
continue
if FileOperationFilterMatcher(filters).matches(path, view):
s.send_notification(Notification.didRenameFiles(rename_file_params))
1 change: 1 addition & 0 deletions stubs/wcmatch/glob.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ from typing import Any, Optional

BRACE: int = ...
GLOBSTAR: int = ...
IGNORECASE: int = ...


def globmatch(
Expand Down