Skip to content

Commit 833fcad

Browse files
committed
Add support for workspace/willRenameFiles LSP request
1 parent c0f71aa commit 833fcad

9 files changed

Lines changed: 574 additions & 9 deletions

File tree

crates/ty_server/src/capabilities.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,29 @@ pub(crate) fn server_capabilities(
484484
supported: Some(true),
485485
change_notifications: Some(true.into()),
486486
}),
487+
file_operations: Some(lsp_types::FileOperationOptions {
488+
will_rename: Some(lsp_types::FileOperationRegistrationOptions {
489+
filters: vec![
490+
lsp_types::FileOperationFilter {
491+
scheme: Some("file".to_string()),
492+
pattern: lsp_types::FileOperationPattern {
493+
glob: "**/*.{py,pyi}".to_string(),
494+
matches: Some(lsp_types::FileOperationPatternKind::File),
495+
options: None,
496+
},
497+
},
498+
lsp_types::FileOperationFilter {
499+
scheme: Some("file".to_string()),
500+
pattern: lsp_types::FileOperationPattern {
501+
glob: "**".to_string(),
502+
matches: Some(lsp_types::FileOperationPatternKind::Folder),
503+
options: None,
504+
},
505+
},
506+
],
507+
}),
508+
..Default::default()
509+
}),
487510
..Default::default()
488511
}),
489512
type_hierarchy_provider: Some(true.into()),

crates/ty_server/src/server/api.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ pub(super) fn request(req: server::Request) -> Task {
108108
>(
109109
req, BackgroundSchedule::Worker
110110
),
111+
requests::WillRenameFilesHandler::METHOD => background_request_task::<
112+
requests::WillRenameFilesHandler,
113+
>(req, BackgroundSchedule::Worker),
111114
requests::PrepareTypeHierarchyRequestHandler::METHOD => background_document_request_task::<
112115
requests::PrepareTypeHierarchyRequestHandler,
113116
>(

crates/ty_server/src/server/api/requests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ mod shutdown;
3838
mod signature_help;
3939
mod type_hierarchy_subtypes;
4040
mod type_hierarchy_supertypes;
41+
mod will_rename_files;
4142
mod workspace_diagnostic;
4243
mod workspace_symbols;
4344

@@ -67,5 +68,6 @@ pub(super) use shutdown::ShutdownHandler;
6768
pub(super) use signature_help::SignatureHelpRequestHandler;
6869
pub(super) use type_hierarchy_subtypes::TypeHierarchySubtypesRequestHandler;
6970
pub(super) use type_hierarchy_supertypes::TypeHierarchySupertypesRequestHandler;
71+
pub(super) use will_rename_files::WillRenameFilesHandler;
7072
pub(super) use workspace_diagnostic::WorkspaceDiagnosticRequestHandler;
7173
pub(super) use workspace_symbols::WorkspaceSymbolRequestHandler;
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
use std::collections::HashMap;
2+
3+
use lsp_types::{RenameFilesParams, TextEdit, Uri, WillRenameFilesRequest, WorkspaceEdit};
4+
use ruff_db::system::SystemPathBuf;
5+
use ruff_db::{Db as _, files::File};
6+
use ty_ide::{PathRename, will_rename_paths_in_files};
7+
use ty_project::{Db as _, ProjectDatabase};
8+
9+
use crate::document::ToRangeExt;
10+
use crate::server::api::traits::{
11+
BackgroundRequestHandler, RequestHandler, RetriableRequestHandler,
12+
};
13+
use crate::session::SessionSnapshot;
14+
use crate::session::client::Client;
15+
16+
/// Handles `workspace/willRenameFiles` requests for Python modules and regular packages.
17+
pub(crate) struct WillRenameFilesHandler;
18+
19+
impl RequestHandler for WillRenameFilesHandler {
20+
type RequestType = WillRenameFilesRequest;
21+
}
22+
23+
impl BackgroundRequestHandler for WillRenameFilesHandler {
24+
fn run(
25+
snapshot: &SessionSnapshot,
26+
client: &Client,
27+
params: RenameFilesParams,
28+
) -> crate::server::Result<Option<WorkspaceEdit>> {
29+
let encoding = snapshot.position_encoding();
30+
let mut all_changes: HashMap<Uri, Vec<TextEdit>> = HashMap::new();
31+
let mut paths = Vec::new();
32+
let mut project_index = None;
33+
34+
for file_rename in &params.files {
35+
let (Some(old_path), Some(new_path)) = (
36+
file_uri_to_path(&file_rename.old_uri),
37+
file_uri_to_path(&file_rename.new_uri),
38+
) else {
39+
return Ok(None);
40+
};
41+
42+
let Some(rename_project_index) = snapshot.project_index_for_path(&old_path) else {
43+
return Ok(None);
44+
};
45+
if snapshot
46+
.enclosing_project_index_for_path(&new_path)
47+
.is_some_and(|index| index != rename_project_index)
48+
{
49+
return Ok(unsupported_move(client));
50+
}
51+
if project_index
52+
.replace(rename_project_index)
53+
.is_some_and(|project_index| project_index != rename_project_index)
54+
{
55+
return Ok(unsupported_move(client));
56+
}
57+
paths.push((old_path, new_path));
58+
}
59+
60+
// Use the project that owns the old path. Files in other workspaces stay unchanged even if
61+
// that project can import them through an extra search path.
62+
let Some(project_index) = project_index else {
63+
return Ok(None);
64+
};
65+
let Some(db) = snapshot.projects().get(project_index) else {
66+
return Ok(None);
67+
};
68+
let Some(workspace_settings) = snapshot.workspace_settings(project_index) else {
69+
return Ok(None);
70+
};
71+
if workspace_settings.is_language_services_disabled() {
72+
return Ok(None);
73+
}
74+
75+
// The filesystem tells file moves from folder moves. A folder without an initializer is a
76+
// namespace package, which the IDE layer deliberately leaves unchanged.
77+
let mut renames = Vec::with_capacity(paths.len());
78+
for (old_path, new_path) in paths {
79+
if db.system().is_directory(&old_path) {
80+
if !["__init__.py", "__init__.pyi"]
81+
.into_iter()
82+
.any(|name| db.system().is_file(&old_path.join(name)))
83+
{
84+
return Ok(None);
85+
}
86+
renames.push(PathRename::directory(old_path, new_path));
87+
} else {
88+
if !matches!(old_path.extension(), Some("py" | "pyi")) {
89+
return Ok(None);
90+
}
91+
if old_path.extension() != new_path.extension() {
92+
return Ok(unsupported_move(client));
93+
}
94+
renames.push(PathRename::file(old_path, new_path));
95+
}
96+
}
97+
98+
let project = db.project();
99+
let indexed_files = project.files(db);
100+
let open_files = project.open_files(db);
101+
let files = (&indexed_files)
102+
.into_iter()
103+
.chain(open_files.iter().copied());
104+
105+
let Ok(edits) = will_rename_paths_in_files(db, &renames, files, |file| {
106+
file_is_in_rename_scope(snapshot, db, project_index, file)
107+
}) else {
108+
return Ok(unsupported_move(client));
109+
};
110+
// Convert ty's file and byte ranges to the URI and position ranges required by LSP.
111+
for edit in edits {
112+
let (file, range, new_text) = edit.into_parts();
113+
if !file_is_in_rename_scope(snapshot, db, project_index, file) {
114+
continue;
115+
}
116+
let Some(range) = range.to_lsp_range(db, file, encoding) else {
117+
return Ok(unsupported_move(client));
118+
};
119+
let Some(location) = range.into_location() else {
120+
return Ok(unsupported_move(client));
121+
};
122+
123+
all_changes.entry(location.uri).or_default().push(TextEdit {
124+
range: location.range,
125+
new_text,
126+
});
127+
}
128+
129+
if all_changes.values_mut().any(|edits| {
130+
edits.sort_by_key(|edit| (edit.range.start, edit.range.end));
131+
edits.dedup();
132+
edits.windows(2).any(|edits| {
133+
edits[0].range.start == edits[1].range.start
134+
|| edits[0].range.end > edits[1].range.start
135+
})
136+
}) {
137+
return Ok(unsupported_move(client));
138+
}
139+
140+
if all_changes.is_empty() {
141+
Ok(None)
142+
} else {
143+
Ok(Some(WorkspaceEdit {
144+
changes: Some(all_changes),
145+
..Default::default()
146+
}))
147+
}
148+
}
149+
}
150+
151+
impl RetriableRequestHandler for WillRenameFilesHandler {
152+
const RETRY_ON_CANCELLATION: bool = true;
153+
}
154+
155+
/// Converts an LSP file URI to a system path.
156+
fn file_uri_to_path(uri: &str) -> Option<SystemPathBuf> {
157+
let uri = Uri::parse(uri).ok()?;
158+
SystemPathBuf::from_path_buf(uri.to_file_path().ok()?).ok()
159+
}
160+
161+
/// Returns whether the selected project owns a file or the file has no on-disk path.
162+
fn file_is_in_rename_scope(
163+
snapshot: &SessionSnapshot,
164+
db: &ProjectDatabase,
165+
project_index: usize,
166+
file: File,
167+
) -> bool {
168+
file.path(db).as_system_path().is_none_or(|path| {
169+
snapshot
170+
.enclosing_project_index_for_path(path)
171+
.is_none_or(|index| index == project_index)
172+
})
173+
}
174+
175+
/// Warns the user that ty deliberately left an unsafe move unchanged.
176+
fn unsupported_move(client: &Client) -> Option<WorkspaceEdit> {
177+
client.show_warning_message(
178+
"ty could not safely update imports and references for this move. \
179+
No automated code changes were applied.",
180+
);
181+
None
182+
}

crates/ty_server/src/session.rs

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,20 +1145,37 @@ impl Session {
11451145

11461146
/// Creates a snapshot of the current state of the [`Session`].
11471147
pub(crate) fn snapshot_session(&self) -> SessionSnapshot {
1148+
// Keep roots and settings in the same order as the cloned databases. The databases remain
1149+
// in their own final field so they are dropped after the snapshot's other shared state.
1150+
let (project_contexts, projects) = self
1151+
.projects
1152+
.iter()
1153+
.map(|(workspace_root, project)| {
1154+
let settings = self
1155+
.workspaces
1156+
.settings_for_path(workspace_root)
1157+
.or_else(|| self.workspaces.settings_virtual_fallback())
1158+
.unwrap_or_default();
1159+
(
1160+
ProjectSnapshotContext {
1161+
workspace_root: workspace_root.clone(),
1162+
settings,
1163+
},
1164+
project.db.clone(),
1165+
)
1166+
})
1167+
.unzip();
1168+
11481169
SessionSnapshot {
1149-
projects: self
1150-
.projects
1151-
.values()
1152-
.map(|project| &project.db)
1153-
.cloned()
1154-
.collect(),
11551170
index: self.index.clone().unwrap(),
11561171
global_settings: self.global_settings.clone(),
11571172
position_encoding: self.position_encoding,
11581173
in_test: self.in_test,
11591174
resolved_client_capabilities: self.resolved_client_capabilities,
11601175
revision: self.revision,
11611176
client_name: self.client_name,
1177+
project_contexts,
1178+
projects,
11621179
}
11631180
}
11641181

@@ -1437,6 +1454,8 @@ pub(crate) struct SessionSnapshot {
14371454
in_test: bool,
14381455
revision: u64,
14391456
client_name: ClientName,
1457+
/// Workspace roots and settings, in the same order as `projects`.
1458+
project_contexts: Vec<ProjectSnapshotContext>,
14401459

14411460
/// IMPORTANT: It's important that the databases come last, or at least,
14421461
/// after any `Arc` that we try to extract or mutate in-place using `Arc::into_inner`
@@ -1451,10 +1470,34 @@ pub(crate) struct SessionSnapshot {
14511470
}
14521471

14531472
impl SessionSnapshot {
1473+
/// Returns the project databases captured by this snapshot.
14541474
pub(crate) fn projects(&self) -> &[ProjectDatabase] {
14551475
&self.projects
14561476
}
14571477

1478+
/// Returns the project owned by the closest enclosing workspace, falling back to the first.
1479+
pub(crate) fn project_index_for_path(&self, path: &SystemPath) -> Option<usize> {
1480+
self.enclosing_project_index_for_path(path)
1481+
.or_else(|| (!self.projects.is_empty()).then_some(0))
1482+
}
1483+
1484+
/// Returns the project owned by the closest enclosing workspace, without a fallback.
1485+
pub(crate) fn enclosing_project_index_for_path(&self, path: &SystemPath) -> Option<usize> {
1486+
self.project_contexts
1487+
.iter()
1488+
.enumerate()
1489+
.filter(|(_, context)| path.starts_with(&context.workspace_root))
1490+
.max_by_key(|(_, context)| context.workspace_root.as_str().len())
1491+
.map(|(index, _)| index)
1492+
}
1493+
1494+
/// Returns the workspace settings associated with `project_index`.
1495+
pub(crate) fn workspace_settings(&self, project_index: usize) -> Option<&WorkspaceSettings> {
1496+
self.project_contexts
1497+
.get(project_index)
1498+
.map(|context| context.settings.as_ref())
1499+
}
1500+
14581501
pub(crate) fn index(&self) -> &Index {
14591502
&self.index
14601503
}
@@ -1487,7 +1530,9 @@ impl SessionSnapshot {
14871530
/// Represents the client (editor) that's connected to the language server.
14881531
#[derive(Debug, Clone, Copy)]
14891532
pub(crate) enum ClientName {
1533+
/// The Zed editor.
14901534
Zed,
1535+
/// Any other editor or an unidentified client.
14911536
Other,
14921537
}
14931538

@@ -1516,6 +1561,14 @@ impl ClientName {
15161561
}
15171562
}
15181563

1564+
/// Workspace information kept alongside each project database in a session snapshot.
1565+
struct ProjectSnapshotContext {
1566+
/// The workspace directory used to decide which project owns a path.
1567+
workspace_root: SystemPathBuf,
1568+
/// The workspace settings in effect when the snapshot was created.
1569+
settings: Arc<WorkspaceSettings>,
1570+
}
1571+
15191572
#[derive(Debug, Default)]
15201573
pub(crate) struct Workspaces {
15211574
workspaces: BTreeMap<SystemPathBuf, Workspace>,

crates/ty_server/tests/e2e/main.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ mod rename;
4343
mod semantic_tokens;
4444
mod signature_help;
4545
mod type_hierarchy;
46+
mod will_rename_files;
4647
mod workspace_folders;
4748

4849
use std::collections::{BTreeMap, HashMap, VecDeque};
@@ -73,9 +74,10 @@ use lsp_types::{
7374
SemanticTokens, ShutdownRequest, SignatureHelp, SignatureHelpParams, SignatureHelpRequest,
7475
SignatureHelpTriggerKind, TextDocumentClientCapabilities, TextDocumentContentChangeEvent,
7576
TextDocumentIdentifier, TextDocumentItem, TextDocumentPositionParams, Uri,
76-
VersionedTextDocumentIdentifier, WorkDoneProgressParams, WorkspaceClientCapabilities,
77-
WorkspaceDiagnosticParams, WorkspaceDiagnosticReport, WorkspaceDiagnosticRequest,
78-
WorkspaceEdit, WorkspaceFolder, WorkspaceFoldersChangeEvent, WorkspaceFoldersInitializeParams,
77+
VersionedTextDocumentIdentifier, WillRenameFilesRequest, WorkDoneProgressParams,
78+
WorkspaceClientCapabilities, WorkspaceDiagnosticParams, WorkspaceDiagnosticReport,
79+
WorkspaceDiagnosticRequest, WorkspaceEdit, WorkspaceFolder, WorkspaceFoldersChangeEvent,
80+
WorkspaceFoldersInitializeParams,
7981
};
8082
use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf, TestSystem};
8183
use rustc_hash::FxHashMap;
@@ -932,6 +934,15 @@ impl TestServer {
932934
)
933935
}
934936

937+
pub(crate) fn will_rename_files(
938+
&mut self,
939+
renames: Vec<lsp_types::FileRename>,
940+
) -> Option<WorkspaceEdit> {
941+
self.send_request_await::<WillRenameFilesRequest>(lsp_types::RenameFilesParams {
942+
files: renames,
943+
})
944+
}
945+
935946
/// Send a `textDocument/diagnostic` request for the document at the given path.
936947
pub(crate) fn document_diagnostic_request(
937948
&mut self,

0 commit comments

Comments
 (0)