Skip to content

Commit eb80d0e

Browse files
kinto0facebook-github-bot
authored andcommitted
implement basic will_rename_files (#1284)
Summary: basic support for willRenameFiles. I found document_changes to be the only way that worked reliably on windows so I implemented both document_changes and changes (depending on the client capability). Differential Revision: D84537597
1 parent f7bdac4 commit eb80d0e

File tree

3 files changed

+335
-13
lines changed

3 files changed

+335
-13
lines changed

pyrefly/lib/lsp/features/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77

88
pub mod hover;
99
pub mod provide_type;
10+
pub mod will_rename_files;
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
use std::collections::HashMap;
9+
use std::sync::Arc;
10+
11+
use lsp_types::DocumentChangeOperation;
12+
use lsp_types::DocumentChanges;
13+
use lsp_types::OneOf;
14+
use lsp_types::OptionalVersionedTextDocumentIdentifier;
15+
use lsp_types::RenameFilesParams;
16+
use lsp_types::TextDocumentEdit;
17+
use lsp_types::TextEdit;
18+
use lsp_types::Url;
19+
use lsp_types::WorkspaceEdit;
20+
use pyrefly_python::PYTHON_EXTENSIONS;
21+
use pyrefly_python::ast::Ast;
22+
use pyrefly_python::module_name::ModuleName;
23+
use pyrefly_python::module_path::ModulePath;
24+
use pyrefly_util::lined_buffer::LinedBuffer;
25+
use pyrefly_util::lock::RwLock;
26+
use rayon::prelude::*;
27+
use ruff_python_ast::Stmt;
28+
use ruff_text_size::Ranged;
29+
30+
use crate::lsp::module_helpers::handle_from_module_path;
31+
use crate::lsp::module_helpers::module_info_to_uri;
32+
use crate::state::state::State;
33+
use crate::state::state::Transaction;
34+
35+
/// Visitor that looks for imports of an old module name and creates TextEdits to update them
36+
struct RenameUsageVisitor<'a> {
37+
edits: Vec<TextEdit>,
38+
old_module_name: &'a ModuleName,
39+
new_module_name: &'a ModuleName,
40+
lined_buffer: &'a LinedBuffer,
41+
}
42+
43+
impl<'a> RenameUsageVisitor<'a> {
44+
fn new(
45+
old_module_name: &'a ModuleName,
46+
new_module_name: &'a ModuleName,
47+
lined_buffer: &'a LinedBuffer,
48+
) -> Self {
49+
Self {
50+
edits: Vec::new(),
51+
old_module_name,
52+
new_module_name,
53+
lined_buffer,
54+
}
55+
}
56+
57+
fn visit_stmt(&mut self, stmt: &Stmt) {
58+
match stmt {
59+
Stmt::Import(import) => {
60+
for alias in &import.names {
61+
let imported_module = ModuleName::from_name(&alias.name.id);
62+
if imported_module == *self.old_module_name
63+
|| imported_module
64+
.as_str()
65+
.starts_with(&format!("{}.", self.old_module_name.as_str()))
66+
{
67+
// Replace the module name
68+
let new_import_name = if imported_module == *self.old_module_name {
69+
self.new_module_name.as_str().to_owned()
70+
} else {
71+
// Replace the prefix
72+
imported_module.as_str().replace(
73+
self.old_module_name.as_str(),
74+
self.new_module_name.as_str(),
75+
)
76+
};
77+
78+
self.edits.push(TextEdit {
79+
range: self.lined_buffer.to_lsp_range(alias.name.range()),
80+
new_text: new_import_name,
81+
});
82+
}
83+
}
84+
}
85+
Stmt::ImportFrom(import_from) => {
86+
if let Some(module) = &import_from.module {
87+
let imported_module = ModuleName::from_name(&module.id);
88+
if imported_module == *self.old_module_name
89+
|| imported_module
90+
.as_str()
91+
.starts_with(&format!("{}.", self.old_module_name.as_str()))
92+
{
93+
// Replace the module name
94+
let new_import_name = if imported_module == *self.old_module_name {
95+
self.new_module_name.as_str().to_owned()
96+
} else {
97+
// Replace the prefix
98+
imported_module.as_str().replace(
99+
self.old_module_name.as_str(),
100+
self.new_module_name.as_str(),
101+
)
102+
};
103+
104+
self.edits.push(TextEdit {
105+
range: self.lined_buffer.to_lsp_range(module.range()),
106+
new_text: new_import_name,
107+
});
108+
}
109+
}
110+
}
111+
_ => {}
112+
}
113+
}
114+
115+
fn take_edits(self) -> Vec<TextEdit> {
116+
self.edits
117+
}
118+
}
119+
120+
/// Handle workspace/willRenameFiles request to update imports when files are renamed.
121+
///
122+
/// This function:
123+
/// 1. Converts file paths to module names
124+
/// 2. Uses get_transitive_rdeps to find all files that depend on the renamed module
125+
/// 3. Uses a visitor pattern to find imports of the old module and creates TextEdits
126+
/// 4. Returns a WorkspaceEdit with all necessary changes
127+
///
128+
/// If the client supports `workspace.workspaceEdit.documentChanges`, the response will use
129+
/// `document_changes` instead of `changes` for better ordering guarantees and version checking.
130+
pub fn will_rename_files(
131+
state: &Arc<State>,
132+
transaction: &Transaction<'_>,
133+
_open_files: &Arc<RwLock<HashMap<std::path::PathBuf, Arc<String>>>>,
134+
params: RenameFilesParams,
135+
supports_document_changes: bool,
136+
) -> Option<WorkspaceEdit> {
137+
eprintln!(
138+
"will_rename_files called with {} file(s)",
139+
params.files.len()
140+
);
141+
142+
let mut all_changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
143+
144+
for file_rename in &params.files {
145+
eprintln!(
146+
" Processing rename: {} -> {}",
147+
file_rename.old_uri, file_rename.new_uri
148+
);
149+
150+
// Convert URLs to paths
151+
let old_uri = match Url::parse(&file_rename.old_uri) {
152+
Ok(uri) => uri,
153+
Err(_) => {
154+
eprintln!(" Failed to parse old_uri");
155+
continue;
156+
}
157+
};
158+
159+
let new_uri = match Url::parse(&file_rename.new_uri) {
160+
Ok(uri) => uri,
161+
Err(_) => {
162+
eprintln!(" Failed to parse new_uri");
163+
continue;
164+
}
165+
};
166+
167+
let old_path = match old_uri.to_file_path() {
168+
Ok(path) => path,
169+
Err(_) => {
170+
eprintln!(" Failed to convert old_uri to path");
171+
continue;
172+
}
173+
};
174+
175+
let new_path = match new_uri.to_file_path() {
176+
Ok(path) => path,
177+
Err(_) => {
178+
eprintln!(" Failed to convert new_uri to path");
179+
continue;
180+
}
181+
};
182+
183+
// Only process Python files
184+
if !PYTHON_EXTENSIONS
185+
.iter()
186+
.any(|ext| old_path.extension().and_then(|e| e.to_str()) == Some(*ext))
187+
{
188+
eprintln!(" Skipping non-Python file");
189+
continue;
190+
}
191+
192+
// Important: only use filesystem handle (never use an in-memory handle)
193+
let old_handle = handle_from_module_path(state, ModulePath::filesystem(old_path.clone()));
194+
let config = state
195+
.config_finder()
196+
.python_file(old_handle.module(), old_handle.path());
197+
198+
// Convert paths to module names
199+
let old_module_name =
200+
ModuleName::from_path(&old_path, config.search_path()).or_else(|| {
201+
// Fallback: try to get module name from the handle
202+
Some(old_handle.module())
203+
});
204+
205+
// For the new module name, we can't rely on from_path because the file doesn't exist yet.
206+
// Instead, we compute the relative path from the old to new file and adjust the module name.
207+
let new_module_name = ModuleName::from_path(&new_path, config.search_path());
208+
209+
let (old_module_name, new_module_name) = match (old_module_name, new_module_name) {
210+
(Some(old), Some(new)) => (old, new),
211+
_ => {
212+
eprintln!(
213+
" Could not determine module names for the rename (old={:?}, new={:?})",
214+
old_module_name, new_module_name
215+
);
216+
continue;
217+
}
218+
};
219+
220+
eprintln!(
221+
" Module rename: {} -> {}",
222+
old_module_name, new_module_name
223+
);
224+
225+
// If module names are the same, no need to update imports
226+
if old_module_name == new_module_name {
227+
eprintln!(" Module names are the same, skipping");
228+
continue;
229+
}
230+
231+
// Use get_transitive_rdeps to find all files that depend on this module
232+
let rdeps = transaction.get_transitive_rdeps(old_handle.clone());
233+
234+
eprintln!(" Found {} transitive rdeps", rdeps.len());
235+
236+
// Visit each dependent file to find and update imports (parallelized)
237+
let rdeps_changes: Vec<(Url, Vec<TextEdit>)> = rdeps
238+
.into_par_iter()
239+
.filter_map(|rdep_handle| {
240+
let module_info = transaction.get_module_info(&rdep_handle)?;
241+
242+
let ast = Ast::parse(module_info.contents()).0;
243+
let mut visitor = RenameUsageVisitor::new(
244+
&old_module_name,
245+
&new_module_name,
246+
module_info.lined_buffer(),
247+
);
248+
249+
for stmt in &ast.body {
250+
visitor.visit_stmt(stmt);
251+
}
252+
253+
let edits_for_file = visitor.take_edits();
254+
255+
if !edits_for_file.is_empty() {
256+
let uri = module_info_to_uri(&module_info)?;
257+
eprintln!(
258+
" Found {} import(s) to update in {}",
259+
edits_for_file.len(),
260+
uri
261+
);
262+
Some((uri, edits_for_file))
263+
} else {
264+
None
265+
}
266+
})
267+
.collect();
268+
269+
// Merge results into all_changes
270+
for (uri, edits) in rdeps_changes {
271+
all_changes.entry(uri).or_default().extend(edits);
272+
}
273+
}
274+
275+
if all_changes.is_empty() {
276+
eprintln!(" No import updates needed");
277+
None
278+
} else {
279+
eprintln!(
280+
" Returning {} file(s) with import updates",
281+
all_changes.len()
282+
);
283+
284+
if supports_document_changes {
285+
// Use document_changes for better ordering guarantees and version checking
286+
let document_changes: Vec<DocumentChangeOperation> = all_changes
287+
.into_iter()
288+
.map(|(uri, edits)| {
289+
DocumentChangeOperation::Edit(TextDocumentEdit {
290+
text_document: OptionalVersionedTextDocumentIdentifier {
291+
uri,
292+
version: None, // None means "any version"
293+
},
294+
edits: edits.into_iter().map(OneOf::Left).collect(),
295+
})
296+
})
297+
.collect();
298+
299+
Some(WorkspaceEdit {
300+
document_changes: Some(DocumentChanges::Operations(document_changes)),
301+
..Default::default()
302+
})
303+
} else {
304+
// Fall back to changes for older clients
305+
Some(WorkspaceEdit {
306+
changes: Some(all_changes),
307+
..Default::default()
308+
})
309+
}
310+
}
311+
}

pyrefly/lib/lsp/server.rs

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ use crate::lsp::features::hover::get_hover;
176176
use crate::lsp::features::provide_type::ProvideType;
177177
use crate::lsp::features::provide_type::ProvideTypeResponse;
178178
use crate::lsp::features::provide_type::provide_type;
179+
use crate::lsp::features::will_rename_files::will_rename_files;
179180
use crate::lsp::lsp::apply_change_events;
180181
use crate::lsp::lsp::as_notification;
181182
use crate::lsp::lsp::as_request;
@@ -908,9 +909,21 @@ impl Server {
908909
{
909910
let transaction =
910911
ide_transaction_manager.non_committable_transaction(&self.state);
912+
let supports_document_changes = self
913+
.initialize_params
914+
.capabilities
915+
.workspace
916+
.as_ref()
917+
.and_then(|w| w.workspace_edit.as_ref())
918+
.and_then(|we| we.document_changes)
919+
.unwrap_or(false);
911920
self.send_response(new_response(
912921
x.id,
913-
Ok(self.will_rename_files(&transaction, params)),
922+
Ok(self.will_rename_files(
923+
&transaction,
924+
params,
925+
supports_document_changes,
926+
)),
914927
));
915928
ide_transaction_manager.save(transaction);
916929
}
@@ -2143,20 +2156,17 @@ impl Server {
21432156

21442157
fn will_rename_files(
21452158
&self,
2146-
_transaction: &Transaction<'_>,
2159+
transaction: &Transaction<'_>,
21472160
params: RenameFilesParams,
2161+
supports_document_changes: bool,
21482162
) -> Option<WorkspaceEdit> {
2149-
// TODO: Implement import updates when files are renamed
2150-
// For now, return None to indicate no edits are needed
2151-
// This is similar to how basedpyright initially implemented this feature
2152-
eprintln!(
2153-
"will_rename_files called with {} file(s)",
2154-
params.files.len()
2155-
);
2156-
for file in &params.files {
2157-
eprintln!(" Renaming: {} -> {}", file.old_uri, file.new_uri);
2158-
}
2159-
None
2163+
will_rename_files(
2164+
&self.state,
2165+
transaction,
2166+
&self.open_files,
2167+
params,
2168+
supports_document_changes,
2169+
)
21602170
}
21612171
}
21622172

0 commit comments

Comments
 (0)