From 21e59aa267eadf6dadd1a87a3d82aca7f626f24c Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Thu, 5 Mar 2026 15:33:07 +0900 Subject: [PATCH 1/5] impl show file ref --- lsp/package.json | 5 + lsp/src/extension.ts | 57 ++++++++++ pyrefly/lib/lsp/non_wasm/safe_delete_file.rs | 100 ++++++++++++------ pyrefly/lib/lsp/non_wasm/server.rs | 9 +- .../lsp/lsp_interaction/safe_delete_file.rs | 98 ++++++++++++++--- 5 files changed, 219 insertions(+), 50 deletions(-) diff --git a/lsp/package.json b/lsp/package.json index 86e764945d..ebdf8c94ff 100644 --- a/lsp/package.json +++ b/lsp/package.json @@ -80,6 +80,11 @@ "title": "Run Test", "category": "pyrefly", "command": "pyrefly.runTest" + }, + { + "title": "Find File Usages", + "category": "pyrefly", + "command": "pyrefly.findFileUsages" } ], "semanticTokenScopes": [ diff --git a/lsp/src/extension.ts b/lsp/src/extension.ts index 0813c7860a..fe3c91527b 100644 --- a/lsp/src/extension.ts +++ b/lsp/src/extension.ts @@ -256,6 +256,63 @@ export async function activate(context: ExtensionContext) { ); registerCodeLensCommands(context, pythonEnv); + context.subscriptions.push( + vscode.commands.registerCommand( + 'pyrefly.findFileUsages', + async (...args: unknown[]) => { + const payload = args[0] as + | { + uri?: string; + locations?: Array<{ + uri: string; + range: { + start: {line: number; character: number}; + end: {line: number; character: number}; + }; + }>; + } + | undefined; + if (payload == null || typeof payload.uri !== 'string') { + return; + } + if (!Array.isArray(payload.locations)) { + return; + } + + const targetUri = vscode.Uri.parse(payload.uri); + const locations = payload.locations + .map(location => { + if ( + location == null || + typeof location.uri !== 'string' || + location.range == null + ) { + return null; + } + const range = new vscode.Range( + location.range.start.line, + location.range.start.character, + location.range.end.line, + location.range.end.character, + ); + return new vscode.Location(vscode.Uri.parse(location.uri), range); + }) + .filter( + (location): location is vscode.Location => location != null, + ); + if (locations.length === 0) { + return; + } + await vscode.commands.executeCommand( + 'editor.action.showReferences', + targetUri, + new vscode.Position(0, 0), + locations, + ); + }, + ), + ); + // When our extension is activated, make sure ms-python knows // TODO(kylei): remove this hack once ms-python has this behavior await triggerMsPythonRefreshLanguageServersIfInstalled(); diff --git a/pyrefly/lib/lsp/non_wasm/safe_delete_file.rs b/pyrefly/lib/lsp/non_wasm/safe_delete_file.rs index 084a18f850..3c222c998a 100644 --- a/pyrefly/lib/lsp/non_wasm/safe_delete_file.rs +++ b/pyrefly/lib/lsp/non_wasm/safe_delete_file.rs @@ -11,10 +11,12 @@ use lsp_types::ClientCapabilities; use lsp_types::CodeAction; use lsp_types::CodeActionKind; use lsp_types::CodeActionOrCommand; +use lsp_types::Command; use lsp_types::DeleteFile; use lsp_types::DeleteFileOptions; use lsp_types::DocumentChangeOperation; use lsp_types::DocumentChanges; +use lsp_types::Location; use lsp_types::ResourceOp; use lsp_types::ResourceOperationKind; use lsp_types::Url; @@ -31,6 +33,9 @@ use ruff_python_ast::StmtImportFrom; use ruff_python_ast::name::Name; use ruff_python_ast::visitor::Visitor; use ruff_python_ast::visitor::walk_stmt; +use ruff_text_size::Ranged; +use ruff_text_size::TextRange; +use serde_json::json; use crate::lsp::non_wasm::module_helpers::handle_from_module_path; use crate::state::state::State; @@ -59,13 +64,13 @@ fn supports_workspace_edit_resource_ops( .all(|kind| supported.is_some_and(|ops| ops.contains(kind))) } -/// Builds a safe-delete refactor action for a file if no import usages are found. -pub(crate) fn safe_delete_file_code_action( +/// Builds safe-delete refactor actions for a file. +pub(crate) fn safe_delete_file_code_actions( capabilities: &ClientCapabilities, state: &State, transaction: &Transaction<'_>, uri: &Url, -) -> Option { +) -> Option> { if !supports_workspace_edit_document_changes(capabilities) { return None; } @@ -88,9 +93,21 @@ pub(crate) fn safe_delete_file_code_action( if module_name == ModuleName::unknown() { return None; } - if has_import_usages(transaction, &handle, module_name, &path) { - return None; + let usage_locations = collect_import_usage_locations(transaction, &handle, module_name, &path); + if !usage_locations.is_empty() { + return Some(vec![ + find_usages_code_action(&file_name, uri, usage_locations), + delete_file_code_action(format!("Delete file `{file_name}` anyway"), uri), + ]); } + + Some(vec![delete_file_code_action( + format!("Safe delete file `{file_name}`"), + uri, + )]) +} + +fn delete_file_code_action(title: String, uri: &Url) -> CodeActionOrCommand { let operation = DocumentChangeOperation::Op(ResourceOp::Delete(DeleteFile { uri: uri.clone(), options: Some(DeleteFileOptions { @@ -99,25 +116,46 @@ pub(crate) fn safe_delete_file_code_action( annotation_id: None, }), })); - Some(CodeActionOrCommand::CodeAction(CodeAction { - title: format!("Safe delete file `{file_name}`"), + CodeActionOrCommand::CodeAction(CodeAction { + title, kind: Some(CodeActionKind::new("refactor.delete")), edit: Some(WorkspaceEdit { document_changes: Some(DocumentChanges::Operations(vec![operation])), ..Default::default() }), ..Default::default() - })) + }) +} + +fn find_usages_code_action( + file_name: &str, + uri: &Url, + locations: Vec, +) -> CodeActionOrCommand { + let title = format!("Find usages of file `{file_name}`"); + CodeActionOrCommand::CodeAction(CodeAction { + title: title.clone(), + command: Some(Command { + title, + command: "pyrefly.findFileUsages".to_owned(), + arguments: Some(vec![json!({ + "uri": uri, + "locations": locations, + })]), + }), + ..Default::default() + }) } -fn has_import_usages( +fn collect_import_usage_locations( transaction: &Transaction<'_>, handle: &Handle, target_module: ModuleName, target_path: &std::path::Path, -) -> bool { +) -> Vec { let rdeps = transaction.get_transitive_rdeps(handle.clone()); let mut seen = HashSet::new(); + let mut locations = Vec::new(); for rdep_handle in rdeps { if !seen.insert(rdep_handle.path().as_path().to_owned()) { continue; @@ -139,46 +177,53 @@ fn has_import_usages( target_module, current_module: module_info.name(), is_init: module_info.path().is_init(), - found: false, + ranges: Vec::new(), }; for stmt in &ast.body { visitor.visit_stmt(stmt); - if visitor.found { - return true; - } + } + if visitor.ranges.is_empty() { + continue; + } + let Some(uri) = Url::from_file_path(module_info.path().as_path()).ok() else { + continue; + }; + for range in visitor.ranges { + locations.push(Location { + uri: uri.clone(), + range: module_info.to_lsp_range(range), + }); } } - false + locations } struct ImportUsageVisitor { target_module: ModuleName, current_module: ModuleName, is_init: bool, - found: bool, + ranges: Vec, } impl ImportUsageVisitor { - fn record_import(&mut self, imported_module: ModuleName) { + fn record_import(&mut self, imported_module: ModuleName, range: TextRange) -> bool { if module_matches_target(imported_module, self.target_module) { - self.found = true; + self.ranges.push(range); + return true; } + false } fn visit_import(&mut self, import: &StmtImport) { for alias in &import.names { let imported_module = ModuleName::from_name(&alias.name.id); - self.record_import(imported_module); - if self.found { + if self.record_import(imported_module, import.range()) { return; } } } fn visit_import_from(&mut self, import_from: &StmtImportFrom) { - if self.found { - return; - } let base = self.current_module.new_maybe_relative( self.is_init, import_from.level, @@ -187,14 +232,12 @@ impl ImportUsageVisitor { let Some(base) = base else { return; }; - self.record_import(base); - if self.found { + if self.record_import(base, import_from.range()) { return; } for alias in &import_from.names { let imported_module = append_module(base, &alias.name.id); - self.record_import(imported_module); - if self.found { + if self.record_import(imported_module, import_from.range()) { return; } } @@ -203,9 +246,6 @@ impl ImportUsageVisitor { impl Visitor<'_> for ImportUsageVisitor { fn visit_stmt(&mut self, stmt: &Stmt) { - if self.found { - return; - } match stmt { Stmt::Import(import) => self.visit_import(import), Stmt::ImportFrom(import_from) => self.visit_import_from(import_from), diff --git a/pyrefly/lib/lsp/non_wasm/server.rs b/pyrefly/lib/lsp/non_wasm/server.rs index 9cdf981f61..665ee10324 100644 --- a/pyrefly/lib/lsp/non_wasm/server.rs +++ b/pyrefly/lib/lsp/non_wasm/server.rs @@ -291,7 +291,10 @@ use crate::lsp::non_wasm::protocol::Response; use crate::lsp::non_wasm::queue::HeavyTaskQueue; use crate::lsp::non_wasm::queue::LspEvent; use crate::lsp::non_wasm::queue::LspQueue; -use crate::lsp::non_wasm::safe_delete_file::safe_delete_file_code_action; +use crate::lsp::non_wasm::safe_delete_file::safe_delete_file_code_actions; +use crate::lsp::non_wasm::stdlib::is_python_stdlib_file; +use crate::lsp::non_wasm::stdlib::no_config_severity_override; +use crate::lsp::non_wasm::stdlib::should_show_error_for_display_mode; use crate::lsp::non_wasm::stdlib::should_show_stdlib_error; use crate::lsp::non_wasm::transaction_manager::TransactionManager; use crate::lsp::non_wasm::type_error_display_status::TypeErrorDisplayStatus; @@ -4602,13 +4605,13 @@ impl Server { record_code_action_telemetry("convert_module_package", start); } let start = Instant::now(); - if let Some(action) = safe_delete_file_code_action( + if let Some(mut file_actions) = safe_delete_file_code_actions( &self.initialize_params.capabilities, &self.state, transaction, uri, ) { - actions.push(action); + actions.append(&mut file_actions); } record_code_action_telemetry("safe_delete_file", start); Ok((!actions.is_empty()).then_some(actions)) diff --git a/pyrefly/lib/test/lsp/lsp_interaction/safe_delete_file.rs b/pyrefly/lib/test/lsp/lsp_interaction/safe_delete_file.rs index dac8a1d62d..a4721a50bf 100644 --- a/pyrefly/lib/test/lsp/lsp_interaction/safe_delete_file.rs +++ b/pyrefly/lib/test/lsp/lsp_interaction/safe_delete_file.rs @@ -11,6 +11,7 @@ use lsp_types::DocumentChanges; use lsp_types::ResourceOp; use lsp_types::Url; use lsp_types::request::CodeActionRequest; +use serde_json::Value; use serde_json::json; use crate::object_model::InitializeSettings; @@ -100,6 +101,9 @@ fn test_safe_delete_file_rejects_usages() { let file = "target.py"; let file_path = root_path.join(file); let uri = Url::from_file_path(&file_path).unwrap(); + let consumer_uri = Url::from_file_path(root_path.join("consumer.py")).unwrap(); + let target_uri_str = uri.to_string(); + let consumer_uri_str = consumer_uri.to_string(); interaction.client.did_open(file); interaction.client.did_open("consumer.py"); @@ -116,14 +120,56 @@ fn test_safe_delete_file_rejects_usages() { })) .expect_response_with(|response: Option>| { let Some(actions) = response else { - return true; + return false; }; - actions.iter().all(|action| { + let mut saw_find = false; + let mut saw_delete = false; + let mut saw_safe = false; + let mut find_command_ok = false; + for action in actions { let CodeActionOrCommand::CodeAction(code_action) = action else { - return true; + continue; }; - code_action.title != "Safe delete file `target.py`" - }) + match code_action.title.as_str() { + "Find usages of file `target.py`" => { + saw_find = true; + let Some(command) = code_action.command else { + continue; + }; + if command.command != "pyrefly.findFileUsages" { + continue; + } + let Some(arguments) = command.arguments else { + continue; + }; + let Some(Value::Object(payload)) = arguments.first() else { + continue; + }; + let Some(Value::String(uri_value)) = payload.get("uri") else { + continue; + }; + if uri_value != &target_uri_str { + continue; + } + let Some(Value::Array(locations)) = payload.get("locations") else { + continue; + }; + if locations.iter().any(|location| { + location + .as_object() + .and_then(|obj| obj.get("uri")) + .and_then(Value::as_str) + .is_some_and(|uri| uri == consumer_uri_str) + }) { + find_command_ok = true; + } + } + "Delete file `target.py` anyway" => saw_delete = true, + "Safe delete file `target.py`" => saw_safe = true, + _ => {} + } + } + saw_find && saw_delete && !saw_safe && find_command_ok }) .unwrap(); @@ -153,16 +199,25 @@ fn test_safe_delete_file_rejects_from_import() { }, "context": { "diagnostics": [] } })) - .expect_response_with(|response| { + .expect_response_with(|response: Option>| { let Some(actions) = response else { - return true; + return false; }; - actions.iter().all(|action| { + let mut saw_find = false; + let mut saw_delete = false; + let mut saw_safe = false; + for action in actions { let CodeActionOrCommand::CodeAction(code_action) = action else { - return true; + continue; }; - code_action.title != "Safe delete file `target.py`" - }) + match code_action.title.as_str() { + "Find usages of file `target.py`" => saw_find = true, + "Delete file `target.py` anyway" => saw_delete = true, + "Safe delete file `target.py`" => saw_safe = true, + _ => {} + } + } + saw_find && saw_delete && !saw_safe }) .unwrap(); @@ -245,16 +300,25 @@ fn test_safe_delete_file_rejects_relative_import() { }, "context": { "diagnostics": [] } })) - .expect_response_with(|response| { + .expect_response_with(|response: Option>| { let Some(actions) = response else { - return true; + return false; }; - actions.iter().all(|action| { + let mut saw_find = false; + let mut saw_delete = false; + let mut saw_safe = false; + for action in actions { let CodeActionOrCommand::CodeAction(code_action) = action else { - return true; + continue; }; - code_action.title != "Safe delete file `target.py`" - }) + match code_action.title.as_str() { + "Find usages of file `target.py`" => saw_find = true, + "Delete file `target.py` anyway" => saw_delete = true, + "Safe delete file `target.py`" => saw_safe = true, + _ => {} + } + } + saw_find && saw_delete && !saw_safe }) .unwrap(); From 39e4397615d5dbf42375c9c68abd291db6e22ff5 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 20 May 2026 15:58:48 +0900 Subject: [PATCH 2/5] . --- pyrefly/lib/lsp/non_wasm/server.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyrefly/lib/lsp/non_wasm/server.rs b/pyrefly/lib/lsp/non_wasm/server.rs index 665ee10324..596b643ba6 100644 --- a/pyrefly/lib/lsp/non_wasm/server.rs +++ b/pyrefly/lib/lsp/non_wasm/server.rs @@ -292,9 +292,6 @@ use crate::lsp::non_wasm::queue::HeavyTaskQueue; use crate::lsp::non_wasm::queue::LspEvent; use crate::lsp::non_wasm::queue::LspQueue; use crate::lsp::non_wasm::safe_delete_file::safe_delete_file_code_actions; -use crate::lsp::non_wasm::stdlib::is_python_stdlib_file; -use crate::lsp::non_wasm::stdlib::no_config_severity_override; -use crate::lsp::non_wasm::stdlib::should_show_error_for_display_mode; use crate::lsp::non_wasm::stdlib::should_show_stdlib_error; use crate::lsp::non_wasm::transaction_manager::TransactionManager; use crate::lsp::non_wasm::type_error_display_status::TypeErrorDisplayStatus; From 0e71e89a080aa14b81341850b1adebb4f6c624cb Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 28 Aug 2026 02:23:43 +0900 Subject: [PATCH 3/5] use get_rdeps --- pyrefly/lib/lsp/non_wasm/safe_delete_file.rs | 36 +++++++++----------- pyrefly/lib/state/state.rs | 8 +++-- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/pyrefly/lib/lsp/non_wasm/safe_delete_file.rs b/pyrefly/lib/lsp/non_wasm/safe_delete_file.rs index d9d9758e14..5f8b7eb1fa 100644 --- a/pyrefly/lib/lsp/non_wasm/safe_delete_file.rs +++ b/pyrefly/lib/lsp/non_wasm/safe_delete_file.rs @@ -21,7 +21,6 @@ use lsp_types::Uri; use lsp_types::WorkspaceEdit; use pyrefly_build::handle::Handle; use pyrefly_python::PYTHON_EXTENSIONS; -use pyrefly_python::ast::Ast; use pyrefly_python::module_name::ModuleName; use pyrefly_python::module_path::ModulePath; use pyrefly_python::module_path::ModulePathDetails; @@ -91,21 +90,21 @@ pub(crate) fn safe_delete_file_code_actions( if module_name == ModuleName::unknown() { return None; } - let usage_locations = collect_import_usage_locations(transaction, &handle, module_name, &path); - if !usage_locations.is_empty() { - return Some(vec![ - find_usages_code_action(&file_name, uri, usage_locations), - delete_file_code_action(format!("Delete file `{file_name}` anyway"), uri), - ]); + let rdeps = transaction.get_rdeps(&handle); + if rdeps.is_empty() { + return Some(vec![delete_file_code_action( + format!("Safe delete file `{file_name}`"), + uri, + )]); } - if transaction.is_depended_on_by_anything(&handle) { + let usage_locations = collect_import_usage_locations(transaction, rdeps, module_name); + if usage_locations.is_empty() { return None; } - - Some(vec![delete_file_code_action( - format!("Safe delete file `{file_name}`"), - uri, - )]) + Some(vec![ + find_usages_code_action(&file_name, uri, usage_locations), + delete_file_code_action(format!("Delete file `{file_name}` anyway"), uri), + ]) } fn delete_file_code_action(title: String, uri: &Uri) -> CodeActionResponse { @@ -151,11 +150,9 @@ fn find_usages_code_action( fn collect_import_usage_locations( transaction: &Transaction<'_>, - handle: &Handle, + rdeps: HashSet, target_module: ModuleName, - target_path: &std::path::Path, ) -> Vec { - let rdeps = transaction.get_transitive_rdeps(handle.clone()); let mut seen = HashSet::new(); let mut locations = Vec::new(); for rdep_handle in rdeps { @@ -165,16 +162,15 @@ fn collect_import_usage_locations( let Some(module_info) = transaction.get_module_info(&rdep_handle) else { continue; }; - if module_info.path().as_path() == target_path { - continue; - } if !matches!( module_info.path().details(), ModulePathDetails::FileSystem(_) | ModulePathDetails::Memory(_) ) { continue; } - let ast = Ast::parse(module_info.contents(), module_info.source_type()).0; + let Some(ast) = transaction.get_ast(&rdep_handle) else { + continue; + }; let mut visitor = ImportUsageVisitor { target_module, current_module: module_info.name(), diff --git a/pyrefly/lib/state/state.rs b/pyrefly/lib/state/state.rs index 96ffed10cd..d2f83691f1 100644 --- a/pyrefly/lib/state/state.rs +++ b/pyrefly/lib/state/state.rs @@ -1113,7 +1113,7 @@ impl<'a> Transaction<'a> { self.get_load(handle).map(|x| x.module_info.dupe()) } - /// Whether any other module depends on this one. + /// Return the modules that directly depend on this one. /// /// A dependency edge means the depending module's result was computed from /// this file's contents, which is exactly the question safe deletion asks, @@ -1128,13 +1128,15 @@ impl<'a> Transaction<'a> { /// filesystem handle: as with `get_transitive_rdeps`, the rdeps of an /// in-memory handle contain only itself, which would make any file an IDE /// has open look unused. - pub fn is_depended_on_by_anything(&self, handle: &Handle) -> bool { + pub fn get_rdeps(&self, handle: &Handle) -> HashSet { let path = handle.path().as_path(); self.get_module(handle) .rdeps .lock() .iter() - .any(|rdep| rdep.path().as_path() != path) + .filter(|rdep| rdep.path().as_path() != path) + .cloned() + .collect() } /// Compute transitive dependency closure for the given handle. From 5dbf1c7440f3b37a969345e7eb6e901079580f34 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 30 Oct 2026 16:40:05 +0900 Subject: [PATCH 4/5] test --- .../lsp/lsp_interaction/safe_delete_file.rs | 394 +++++++++++++++--- .../consumer.py | 8 + .../package/__init__.py | 8 + .../package/target.py | 6 + .../pyrefly.toml | 1 + 5 files changed, 355 insertions(+), 62 deletions(-) create mode 100644 pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/consumer.py create mode 100644 pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/package/__init__.py create mode 100644 pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/package/target.py create mode 100644 pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/pyrefly.toml diff --git a/pyrefly/lib/test/lsp/lsp_interaction/safe_delete_file.rs b/pyrefly/lib/test/lsp/lsp_interaction/safe_delete_file.rs index a0dc3f5c60..518839c902 100644 --- a/pyrefly/lib/test/lsp/lsp_interaction/safe_delete_file.rs +++ b/pyrefly/lib/test/lsp/lsp_interaction/safe_delete_file.rs @@ -5,20 +5,31 @@ * LICENSE file in the root directory of this source tree. */ +use std::fs; + +use lsp_types::CodeActionKind; use lsp_types::CodeActionRequest; use lsp_types::CodeActionResponse; use lsp_types::DocumentChange; +use lsp_types::Location; use lsp_types::Uri; +use pyrefly_lsp_test::IndexingMode; use pyrefly_lsp_test::object_model::InitializeSettings; use pyrefly_lsp_test::object_model::LspInteraction; -use serde_json::Value; +use pyrefly_lsp_test::object_model::LspInteractionArgs; use serde_json::json; use crate::test::lsp::lsp_interaction::util::get_test_files_root; -fn init_with_delete_support(root_path: &std::path::Path) -> (LspInteraction, Uri) { +fn init_with_delete_support( + root_path: &std::path::Path, + supports_find_usages: bool, + indexing_mode: IndexingMode, +) -> (LspInteraction, Uri) { let scope_uri = Uri::from_file_path(root_path).unwrap(); - let mut interaction = LspInteraction::new(); + let mut args = LspInteractionArgs::default(); + args.args.indexing_mode = indexing_mode; + let mut interaction = LspInteraction::new_with_args(args); interaction.set_root(root_path.to_path_buf()); interaction .initialize(InitializeSettings { @@ -31,17 +42,45 @@ fn init_with_delete_support(root_path: &std::path::Path) -> (LspInteraction, Uri } } })), + initialization_options: supports_find_usages + .then(|| json!({"pyrefly": {"findFileUsages": true}})), ..Default::default() }) .unwrap(); (interaction, scope_uri) } +fn find_usage_locations( + actions: &[CodeActionResponse], + file_name: &str, + target_uri: &Uri, +) -> Option> { + let title = format!("Find usages of file `{file_name}`"); + let code_action = actions.iter().find_map(|action| { + let CodeActionResponse::CodeAction(code_action) = action else { + return None; + }; + (code_action.title == title).then_some(code_action) + })?; + if code_action.kind.as_ref()? != &CodeActionKind::new("refactor.delete") { + return None; + } + let command = code_action.command.as_ref()?; + if command.command != "pyrefly.findFileUsages" { + return None; + } + let payload = command.arguments.as_ref()?.first()?.as_object()?; + if payload.get("uri")? != &json!(target_uri) { + return None; + } + serde_json::from_value(payload.get("locations")?.clone()).ok() +} + #[test] fn test_safe_delete_file_unused() { let root = get_test_files_root(); let root_path = root.path().join("safe_delete_file"); - let (interaction, _scope_uri) = init_with_delete_support(&root_path); + let (interaction, _scope_uri) = init_with_delete_support(&root_path, true, IndexingMode::None); let file = "unused.py"; let file_path = root_path.join(file); @@ -90,20 +129,93 @@ fn test_safe_delete_file_unused() { } #[test] -fn test_safe_delete_file_rejects_usages() { +fn test_safe_delete_file_finds_usage_in_indexed_closed_file() { let root = get_test_files_root(); let root_path = root.path().join("safe_delete_file"); - let (interaction, _scope_uri) = init_with_delete_support(&root_path); + let (interaction, _scope_uri) = + init_with_delete_support(&root_path, true, IndexingMode::LazyBlocking); let file = "target.py"; let file_path = root_path.join(file); let uri = Uri::from_file_path(&file_path).unwrap(); let consumer_uri = Uri::from_file_path(root_path.join("consumer.py")).unwrap(); - let target_uri_str = uri.to_string(); - let consumer_uri_str = consumer_uri.to_string(); interaction.client.did_open(file); + + interaction + .client + .send_request::(json!({ + "textDocument": { "uri": uri }, + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "context": { "diagnostics": [] } + })) + .expect_response_with(|response: Option>| { + let Some(actions) = response else { + return false; + }; + let locations = find_usage_locations(&actions, file, &uri).unwrap_or_default(); + let has_consumer = locations + .iter() + .any(|location| location.uri == consumer_uri); + let has_delete = actions.iter().any(|action| { + matches!( + action, + CodeActionResponse::CodeAction(action) + if action.title == "Delete file `target.py` anyway" + ) + }); + let has_safe = actions.iter().any(|action| { + matches!( + action, + CodeActionResponse::CodeAction(action) + if action.title == "Safe delete file `target.py`" + ) + }); + has_consumer && has_delete && !has_safe + }) + .unwrap(); + + interaction.shutdown().unwrap(); +} + +#[test] +fn test_safe_delete_file_prefers_open_importer() { + let root = get_test_files_root(); + let root_path = root.path().join("safe_delete_file"); + let (interaction, _scope_uri) = + init_with_delete_support(&root_path, true, IndexingMode::LazyBlocking); + + let file = "target.py"; + let uri = Uri::from_file_path(root_path.join(file)).unwrap(); + let consumer_uri = Uri::from_file_path(root_path.join("consumer.py")).unwrap(); + interaction.client.did_open(file); + // Complete indexing before opening a buffer for the indexed importer. + interaction + .client + .send_request::(json!({ + "textDocument": { "uri": uri }, + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "context": { "diagnostics": [] } + })) + .expect_response_with(|response: Option>| { + let actions = response.expect("expected file actions after indexing"); + let locations = find_usage_locations(&actions, file, &uri) + .expect("expected usages from the indexed importer"); + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].uri, consumer_uri); + true + }) + .unwrap(); interaction.client.did_open("consumer.py"); + interaction + .client + .did_change("consumer.py", "\n\nimport target\n"); interaction .client @@ -119,54 +231,207 @@ fn test_safe_delete_file_rejects_usages() { let Some(actions) = response else { return false; }; - let mut saw_find = false; - let mut saw_delete = false; - let mut saw_safe = false; - let mut find_command_ok = false; - for action in actions { - let CodeActionResponse::CodeAction(code_action) = action else { - continue; - }; - match code_action.title.as_str() { - "Find usages of file `target.py`" => { - saw_find = true; - let Some(command) = code_action.command else { - continue; - }; - if command.command != "pyrefly.findFileUsages" { - continue; - } - let Some(arguments) = command.arguments else { - continue; - }; - let Some(Value::Object(payload)) = arguments.first() else { - continue; - }; - let Some(Value::String(uri_value)) = payload.get("uri") else { - continue; - }; - if uri_value != &target_uri_str { - continue; - } - let Some(Value::Array(locations)) = payload.get("locations") else { - continue; - }; - if locations.iter().any(|location| { - location - .as_object() - .and_then(|obj| obj.get("uri")) - .and_then(Value::as_str) - .is_some_and(|uri| uri == consumer_uri_str) - }) { - find_command_ok = true; - } - } - "Delete file `target.py` anyway" => saw_delete = true, - "Safe delete file `target.py`" => saw_safe = true, - _ => {} - } - } - saw_find && saw_delete && !saw_safe && find_command_ok + let locations = find_usage_locations(&actions, file, &uri) + .expect("expected usages from the open importer"); + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].uri, consumer_uri); + assert_eq!(locations[0].range.start.line, 2); + true + }) + .unwrap(); + + interaction + .client + .did_change("consumer.py", "answer = 123\n"); + interaction + .client + .send_request::(json!({ + "textDocument": { "uri": uri }, + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "context": { "diagnostics": [] } + })) + .expect_response_with(|response: Option>| { + let actions = + response.expect("expected a delete-anyway action for indexed dependencies"); + assert!(find_usage_locations(&actions, file, &uri).is_none()); + assert!(actions.iter().any(|action| { + matches!(action, CodeActionResponse::CodeAction(action) + if action.title == "Delete file `target.py` anyway") + })); + assert!(actions.iter().all(|action| { + !matches!(action, CodeActionResponse::CodeAction(action) + if action.title == "Safe delete file `target.py`") + })); + true + }) + .unwrap(); + + interaction.shutdown().unwrap(); +} + +#[test] +fn test_safe_delete_file_finds_package_reexports() { + for chained in [false, true] { + let root = get_test_files_root(); + let root_path = root.path().join("safe_delete_file_relative"); + fs::write(root_path.join("pkg/__init__.py"), "from . import target\n").unwrap(); + let consumer_source = if chained { + fs::write( + root_path.join("bridge.py"), + "from pkg import target as exported\n", + ) + .unwrap(); + "from bridge import exported as target\ntarget.func()\n" + } else { + "from pkg import target\ntarget.func()\n" + }; + fs::write(root_path.join("consumer.py"), consumer_source).unwrap(); + let (interaction, _scope_uri) = + init_with_delete_support(&root_path, true, IndexingMode::LazyBlocking); + let uri = Uri::from_file_path(root_path.join("pkg/target.py")).unwrap(); + let package_uri = Uri::from_file_path(root_path.join("pkg/__init__.py")).unwrap(); + let consumer_uri = Uri::from_file_path(root_path.join("consumer.py")).unwrap(); + interaction.client.did_open("pkg/target.py"); + interaction + .client + .send_request::(json!({ + "textDocument": { "uri": uri }, + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "context": { "diagnostics": [] } + })) + .expect_response_with(|response: Option>| { + let actions = response.expect("expected actions for a re-exported submodule"); + let locations = find_usage_locations(&actions, "target.py", &uri) + .expect("expected references through package re-exports"); + assert!(locations.iter().any(|location| location.uri == package_uri)); + assert!( + locations + .iter() + .any(|location| location.uri == consumer_uri) + ); + assert!(actions.iter().any(|action| { + matches!(action, CodeActionResponse::CodeAction(action) + if action.title == "Delete file `target.py` anyway") + })); + true + }) + .unwrap(); + interaction.shutdown().unwrap(); + } +} + +#[test] +fn test_safe_delete_file_negotiates_find_usages_command() { + let root = get_test_files_root(); + let root_path = root.path().join("safe_delete_file"); + let (interaction, _scope_uri) = init_with_delete_support(&root_path, false, IndexingMode::None); + + let file = "target.py"; + let uri = Uri::from_file_path(root_path.join(file)).unwrap(); + interaction.client.did_open(file); + interaction.client.did_open("consumer.py"); + + interaction + .client + .send_request::(json!({ + "textDocument": { "uri": uri }, + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "context": { "diagnostics": [] } + })) + .expect_response_with(|response: Option>| { + let Some(actions) = response else { + return false; + }; + let titles = actions.iter().filter_map(|action| match action { + CodeActionResponse::CodeAction(action) => Some(action.title.as_str()), + CodeActionResponse::Command(_) => None, + }); + let titles = titles.collect::>(); + titles.contains(&"Delete file `target.py` anyway") + && !titles.contains(&"Find usages of file `target.py`") + }) + .unwrap(); + + interaction.shutdown().unwrap(); +} + +#[test] +fn test_safe_delete_file_maps_notebook_usage_to_cell() { + let root = get_test_files_root(); + let root_path = root.path().join("safe_delete_file"); + let (interaction, _scope_uri) = init_with_delete_support(&root_path, true, IndexingMode::None); + + let file = "target.py"; + let uri = Uri::from_file_path(root_path.join(file)).unwrap(); + interaction.client.did_open(file); + interaction.open_notebook("consumer.ipynb", vec!["import target"]); + let cell_uri = interaction.cell_uri("consumer.ipynb", "cell1"); + + interaction + .client + .send_request::(json!({ + "textDocument": { "uri": uri }, + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "context": { "diagnostics": [] } + })) + .expect_response_with(|response: Option>| { + let Some(actions) = response else { + return false; + }; + find_usage_locations(&actions, file, &uri) + .is_some_and(|locations| locations.iter().any(|location| location.uri == cell_uri)) + }) + .unwrap(); + + interaction.shutdown().unwrap(); +} + +#[test] +fn test_safe_delete_file_does_not_report_attribute_import_as_submodule() { + let root = get_test_files_root(); + let root_path = root.path().join("safe_delete_file_attribute_import"); + let (interaction, _scope_uri) = init_with_delete_support(&root_path, true, IndexingMode::None); + + let file = "package/target.py"; + let uri = Uri::from_file_path(root_path.join(file)).unwrap(); + let package_uri = Uri::from_file_path(root_path.join("package/__init__.py")).unwrap(); + let consumer_uri = Uri::from_file_path(root_path.join("consumer.py")).unwrap(); + interaction.client.did_open(file); + interaction.client.did_open("package/__init__.py"); + interaction.client.did_open("consumer.py"); + + interaction + .client + .send_request::(json!({ + "textDocument": { "uri": uri }, + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "context": { "diagnostics": [] } + })) + .expect_response_with(|response: Option>| { + let Some(actions) = response else { + return false; + }; + find_usage_locations(&actions, "target.py", &uri).is_some_and(|locations| { + locations.iter().any(|location| location.uri == package_uri) + && locations + .iter() + .all(|location| location.uri != consumer_uri) + }) }) .unwrap(); @@ -177,7 +442,7 @@ fn test_safe_delete_file_rejects_usages() { fn test_safe_delete_file_rejects_from_import() { let root = get_test_files_root(); let root_path = root.path().join("safe_delete_file_from_import"); - let (interaction, _scope_uri) = init_with_delete_support(&root_path); + let (interaction, _scope_uri) = init_with_delete_support(&root_path, true, IndexingMode::None); let file = "target.py"; let file_path = root_path.join(file); @@ -225,7 +490,7 @@ fn test_safe_delete_file_rejects_from_import() { fn test_safe_delete_file_from_import_unused() { let root = get_test_files_root(); let root_path = root.path().join("safe_delete_file_from_import"); - let (interaction, _scope_uri) = init_with_delete_support(&root_path); + let (interaction, _scope_uri) = init_with_delete_support(&root_path, true, IndexingMode::None); let file = "unused.py"; let file_path = root_path.join(file); @@ -278,7 +543,7 @@ fn test_safe_delete_file_from_import_unused() { fn test_safe_delete_file_rejects_relative_import() { let root = get_test_files_root(); let root_path = root.path().join("safe_delete_file_relative"); - let (interaction, _scope_uri) = init_with_delete_support(&root_path); + let (interaction, _scope_uri) = init_with_delete_support(&root_path, true, IndexingMode::None); let file = "pkg/target.py"; let file_path = root_path.join(file); @@ -325,12 +590,12 @@ fn test_safe_delete_file_rejects_relative_import() { /// A Configerator `.cinc` file reaches its Thrift stub through an /// `import_thrift(...)` call rather than an `import` statement. The dependency /// is real -- deleting the stub breaks the config -- and the dependency graph -/// records it, so safe delete must not offer to remove it. +/// records it, so deletion must carry the "anyway" warning. #[test] fn test_safe_delete_file_rejects_special_import() { let root = get_test_files_root(); let root_path = root.path().join("safe_delete_special_import"); - let (interaction, _scope_uri) = init_with_delete_support(&root_path); + let (interaction, _scope_uri) = init_with_delete_support(&root_path, true, IndexingMode::None); interaction.client.did_open("config.cinc"); @@ -346,7 +611,12 @@ fn test_safe_delete_file_rejects_special_import() { "context": { "diagnostics": [] } })) .expect_response_with(|response: Option>| { - response.unwrap_or_default().iter().all(|action| { + let actions = response.expect("expected a delete-anyway action for a special import"); + assert!(actions.iter().any(|action| { + matches!(action, CodeActionResponse::CodeAction(action) + if action.title == "Delete file `types.thrift.pyi` anyway") + })); + actions.iter().all(|action| { let CodeActionResponse::CodeAction(code_action) = action else { return true; }; diff --git a/pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/consumer.py b/pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/consumer.py new file mode 100644 index 0000000000..982ca1967d --- /dev/null +++ b/pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/consumer.py @@ -0,0 +1,8 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +from package import target + +print(target) diff --git a/pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/package/__init__.py b/pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/package/__init__.py new file mode 100644 index 0000000000..8d5e4d9667 --- /dev/null +++ b/pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/package/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +from .target import value + +target = value diff --git a/pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/package/target.py b/pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/package/target.py new file mode 100644 index 0000000000..ce611ceaae --- /dev/null +++ b/pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/package/target.py @@ -0,0 +1,6 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +value = 1 diff --git a/pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/pyrefly.toml b/pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/pyrefly.toml new file mode 100644 index 0000000000..54029c46ff --- /dev/null +++ b/pyrefly/lib/test/lsp/lsp_interaction/test_files/safe_delete_file_attribute_import/pyrefly.toml @@ -0,0 +1 @@ +search_path = ["."] From 5f9983ddd66c238b26228d304559b30c59622207 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 30 Oct 2026 16:40:09 +0900 Subject: [PATCH 5/5] fix --- lsp/src/extension.ts | 1 + pyrefly/lib/lsp/non_wasm/safe_delete_file.rs | 139 ++++++++++++++----- pyrefly/lib/lsp/non_wasm/server.rs | 16 +++ pyrefly/lib/state/lsp.rs | 3 +- 4 files changed, 123 insertions(+), 36 deletions(-) diff --git a/lsp/src/extension.ts b/lsp/src/extension.ts index e895fb563e..b3456861a4 100644 --- a/lsp/src/extension.ts +++ b/lsp/src/extension.ts @@ -285,6 +285,7 @@ export async function activate( ...((rawInitialisationOptions as any).pyrefly ?? {}), typeErrorDisplayStatusVersion: TYPE_ERROR_DISPLAY_STATUS_VERSION, customHoverProvider: supportsHoverVerbosity, + findFileUsages: true, pushTypeErrorDisplayStatus: true, }, }; diff --git a/pyrefly/lib/lsp/non_wasm/safe_delete_file.rs b/pyrefly/lib/lsp/non_wasm/safe_delete_file.rs index 5f8b7eb1fa..cedb914ad0 100644 --- a/pyrefly/lib/lsp/non_wasm/safe_delete_file.rs +++ b/pyrefly/lib/lsp/non_wasm/safe_delete_file.rs @@ -6,6 +6,8 @@ */ use std::collections::HashSet; +use std::path::Path; +use std::sync::Arc; use lsp_types::ClientCapabilities; use lsp_types::CodeAction; @@ -21,13 +23,14 @@ use lsp_types::Uri; use lsp_types::WorkspaceEdit; use pyrefly_build::handle::Handle; use pyrefly_python::PYTHON_EXTENSIONS; +use pyrefly_python::ast::Ast; +use pyrefly_python::module::TextRangeWithModule; use pyrefly_python::module_name::ModuleName; use pyrefly_python::module_path::ModulePath; use pyrefly_python::module_path::ModulePathDetails; use ruff_python_ast::Stmt; use ruff_python_ast::StmtImport; use ruff_python_ast::StmtImportFrom; -use ruff_python_ast::name::Name; use ruff_python_ast::visitor::Visitor; use ruff_python_ast::visitor::walk_stmt; use ruff_text_size::Ranged; @@ -35,6 +38,7 @@ use ruff_text_size::TextRange; use serde_json::json; use crate::lsp::non_wasm::module_helpers::handle_from_module_path; +use crate::state::lsp::FindPreference; use crate::state::state::State; use crate::state::state::Transaction; @@ -64,9 +68,12 @@ fn supports_workspace_edit_resource_ops( /// Builds safe-delete refactor actions for a file. pub(crate) fn safe_delete_file_code_actions( capabilities: &ClientCapabilities, + supports_find_usages: bool, state: &State, transaction: &Transaction<'_>, uri: &Uri, + get_open_handle: impl Fn(&Path) -> Option, + to_lsp_location: impl Fn(&TextRangeWithModule) -> Option, ) -> Option> { if !supports_workspace_edit_document_changes(capabilities) { return None; @@ -97,14 +104,19 @@ pub(crate) fn safe_delete_file_code_actions( uri, )]); } - let usage_locations = collect_import_usage_locations(transaction, rdeps, module_name); - if usage_locations.is_empty() { - return None; + let mut actions = Vec::new(); + if supports_find_usages { + let usages = collect_import_usages(transaction, rdeps, module_name, get_open_handle); + let locations: Vec<_> = usages.iter().filter_map(to_lsp_location).collect(); + if !locations.is_empty() { + actions.push(find_usages_code_action(&file_name, uri, locations)); + } } - Some(vec![ - find_usages_code_action(&file_name, uri, usage_locations), - delete_file_code_action(format!("Delete file `{file_name}` anyway"), uri), - ]) + actions.push(delete_file_code_action( + format!("Delete file `{file_name}` anyway"), + uri, + )); + Some(actions) } fn delete_file_code_action(title: String, uri: &Uri) -> CodeActionResponse { @@ -135,6 +147,7 @@ fn find_usages_code_action( let title = format!("Find usages of file `{file_name}`"); CodeActionResponse::CodeAction(CodeAction { title: title.clone(), + kind: Some(CodeActionKind::Custom("refactor.delete".into())), command: Some(Command { title, tooltip: None, @@ -148,14 +161,17 @@ fn find_usages_code_action( }) } -fn collect_import_usage_locations( +fn collect_import_usages( transaction: &Transaction<'_>, rdeps: HashSet, target_module: ModuleName, -) -> Vec { + get_open_handle: impl Fn(&Path) -> Option, +) -> Vec { let mut seen = HashSet::new(); - let mut locations = Vec::new(); + let mut usages = Vec::new(); for rdep_handle in rdeps { + // An open buffer can remove its dependency while the indexed disk file retains it. + let rdep_handle = get_open_handle(rdep_handle.path().as_path()).unwrap_or(rdep_handle); if !seen.insert(rdep_handle.path().as_path().to_owned()) { continue; } @@ -168,10 +184,13 @@ fn collect_import_usage_locations( ) { continue; } - let Some(ast) = transaction.get_ast(&rdep_handle) else { - continue; - }; + // Only indexed closed files without a retained AST need parsing. + let ast = transaction.get_ast(&rdep_handle).unwrap_or_else(|| { + Arc::new(Ast::parse(module_info.contents(), module_info.source_type()).0) + }); let mut visitor = ImportUsageVisitor { + transaction, + importer: &rdep_handle, target_module, current_module: module_info.name(), is_init: module_info.path().is_init(), @@ -183,27 +202,23 @@ fn collect_import_usage_locations( if visitor.ranges.is_empty() { continue; } - let Some(uri) = Uri::from_file_path(module_info.path().as_path()).ok() else { - continue; - }; for range in visitor.ranges { - locations.push(Location { - uri: uri.clone(), - range: module_info.to_lsp_range(range), - }); + usages.push(TextRangeWithModule::new(module_info.clone(), range)); } } - locations + usages } -struct ImportUsageVisitor { +struct ImportUsageVisitor<'a> { + transaction: &'a Transaction<'a>, + importer: &'a Handle, target_module: ModuleName, current_module: ModuleName, is_init: bool, ranges: Vec, } -impl ImportUsageVisitor { +impl ImportUsageVisitor<'_> { fn record_import(&mut self, imported_module: ModuleName, range: TextRange) -> bool { if module_matches_target(imported_module, self.target_module) { self.ranges.push(range); @@ -234,15 +249,26 @@ impl ImportUsageVisitor { return; } for alias in &import_from.names { - let imported_module = append_module(base, &alias.name.id); - if self.record_import(imported_module, import_from.range()) { + let refers_to_target = self + .transaction + .resolve_named_import( + self.importer, + base, + alias.name.id.clone(), + FindPreference::default(), + ) + .is_some_and(|(handle, _, _)| { + module_matches_target(handle.module(), self.target_module) + }); + if refers_to_target { + self.ranges.push(import_from.range()); return; } } } } -impl Visitor<'_> for ImportUsageVisitor { +impl Visitor<'_> for ImportUsageVisitor<'_> { fn visit_stmt(&mut self, stmt: &Stmt) { match stmt { Stmt::Import(import) => self.visit_import(import), @@ -252,14 +278,6 @@ impl Visitor<'_> for ImportUsageVisitor { } } -fn append_module(base: ModuleName, suffix: &Name) -> ModuleName { - if base.as_str().is_empty() { - ModuleName::from_name(suffix) - } else { - base.append(suffix) - } -} - fn module_matches_target(module: ModuleName, target: ModuleName) -> bool { if module == target { return true; @@ -270,3 +288,54 @@ fn module_matches_target(module: ModuleName, target: ModuleName) -> bool { && module_str.len() > target_str.len() && module_str.as_bytes().get(target_str.len()) == Some(&b'.') } + +#[cfg(test)] +mod tests { + use std::fs; + + use lsp_types::ClientCapabilities; + use lsp_types::CodeActionResponse; + use lsp_types::Uri; + use serde_json::json; + use tempfile::tempdir; + + use super::safe_delete_file_code_actions; + use crate::test::util::TestEnv; + + #[test] + fn test_safe_delete_file_skips_locations_for_unsupported_clients() { + let root = tempdir().unwrap(); + let target_path = root.path().join("target.py"); + let consumer_path = root.path().join("consumer.py"); + fs::write(&target_path, "value = 1\n").unwrap(); + fs::write(&consumer_path, "import target\n").unwrap(); + let mut env = TestEnv::new(); + env.add_real_path("target", target_path.clone()); + env.add_real_path("consumer", consumer_path); + let (state, handle) = env.to_state(); + let transaction = state.transaction(); + assert!(!transaction.get_rdeps(&handle("target")).is_empty()); + let capabilities: ClientCapabilities = serde_json::from_value(json!({ + "workspace": { + "workspaceEdit": { + "documentChanges": true, + "resourceOperations": ["delete"] + } + } + })) + .unwrap(); + let actions = safe_delete_file_code_actions( + &capabilities, + false, + &state, + &transaction, + &Uri::from_file_path(target_path).unwrap(), + |_| panic!("unsupported clients must not inspect importer buffers"), + |_| panic!("unsupported clients must not convert locations"), + ) + .unwrap(); + assert_eq!(actions.len(), 1); + assert!(matches!(&actions[0], CodeActionResponse::CodeAction(action) + if action.title == "Delete file `target.py` anyway")); + } +} diff --git a/pyrefly/lib/lsp/non_wasm/server.rs b/pyrefly/lib/lsp/non_wasm/server.rs index 27e82c97a7..0708c609ba 100644 --- a/pyrefly/lib/lsp/non_wasm/server.rs +++ b/pyrefly/lib/lsp/non_wasm/server.rs @@ -5480,11 +5480,27 @@ impl Server { } record_code_action_telemetry("move_symbol_new_file", start); let start = Instant::now(); + let supports_find_usages = self + .initialize_params + .initialization_options + .as_ref() + .and_then(|options| options.get("pyrefly")) + .and_then(|options| options.get("findFileUsages")) + .and_then(Value::as_bool) + .unwrap_or(false); if let Some(mut file_actions) = safe_delete_file_code_actions( &self.initialize_params.capabilities, + supports_find_usages, &self.state, transaction, uri, + |path| { + self.open_files + .read() + .contains_key(path) + .then(|| make_open_handle(&self.state, path)) + }, + |location| self.to_lsp_location(location), ) { actions.append(&mut file_actions); } diff --git a/pyrefly/lib/state/lsp.rs b/pyrefly/lib/state/lsp.rs index 1da1f71bfd..822fe0e7d7 100644 --- a/pyrefly/lib/state/lsp.rs +++ b/pyrefly/lib/state/lsp.rs @@ -1734,7 +1734,8 @@ impl<'a> Transaction<'a> { } } - fn resolve_named_import( + /// Resolve a named import through re-exports and package submodules. + pub(crate) fn resolve_named_import( &self, handle: &Handle, module_name: ModuleName,