Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions lsp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@
"title": "Run Test",
"category": "pyrefly",
"command": "pyrefly.runTest"
},
{
"title": "Find File Usages",
"category": "pyrefly",
"command": "pyrefly.findFileUsages"
}
],
"semanticTokenScopes": [
Expand Down
58 changes: 58 additions & 0 deletions lsp/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export async function activate(
...((rawInitialisationOptions as any).pyrefly ?? {}),
typeErrorDisplayStatusVersion: TYPE_ERROR_DISPLAY_STATUS_VERSION,
customHoverProvider: supportsHoverVerbosity,
findFileUsages: true,
pushTypeErrorDisplayStatus: true,
},
};
Expand Down Expand Up @@ -510,6 +511,63 @@ export async function activate(
);
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();
Expand Down
218 changes: 211 additions & 7 deletions pyrefly/lib/lsp/non_wasm/safe_delete_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,38 @@
* LICENSE file in the root directory of this source tree.
*/

use std::collections::HashSet;

use lsp_types::ClientCapabilities;
use lsp_types::CodeAction;
use lsp_types::CodeActionKind;
use lsp_types::CodeActionResponse;
use lsp_types::Command;
use lsp_types::DeleteFile;
use lsp_types::DeleteFileOptions;
use lsp_types::DocumentChange;
use lsp_types::Location;
use lsp_types::ResourceOperationKind;
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;
use ruff_text_size::TextRange;
use serde_json::json;

use crate::export::exports::ExportLocation;
use crate::lsp::non_wasm::module_helpers::handle_from_module_path;
use crate::state::state::State;
use crate::state::state::Transaction;
Expand Down Expand Up @@ -46,13 +64,15 @@ 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 that nothing imports.
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,
supports_find_usages: bool,
state: &State,
transaction: &Transaction<'_>,
uri: &Uri,
) -> Option<CodeActionResponse> {
to_lsp_location: impl Fn(&TextRangeWithModule) -> Option<Location>,
) -> Option<Vec<CodeActionResponse>> {
if !supports_workspace_edit_document_changes(capabilities) {
return None;
}
Expand All @@ -75,9 +95,32 @@ pub(crate) fn safe_delete_file_code_action(
if module_name == ModuleName::unknown() {
return None;
}
if transaction.is_depended_on_by_anything(&handle) {
let rdeps = transaction.get_rdeps(&handle);
if rdeps.is_empty() {
return Some(vec![delete_file_code_action(
format!("Safe delete file `{file_name}`"),
uri,
)]);
}
let usages = collect_import_usages(transaction, rdeps, module_name);
if usages.is_empty() {
return None;
}
let mut actions = Vec::new();
if supports_find_usages {
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));
}
}
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 {
let operation = DocumentChange::DeleteFile(DeleteFile {
uri: uri.clone(),
options: Some(DeleteFileOptions {
Expand All @@ -86,13 +129,174 @@ pub(crate) fn safe_delete_file_code_action(
}),
annotation_id: None,
});
Some(CodeActionResponse::CodeAction(CodeAction {
title: format!("Safe delete file `{file_name}`"),
CodeActionResponse::CodeAction(CodeAction {
title,
kind: Some(CodeActionKind::Custom("refactor.delete".into())),
edit: Some(WorkspaceEdit {
document_changes: Some(vec![operation]),
..Default::default()
}),
..Default::default()
}))
})
}

fn find_usages_code_action(
file_name: &str,
uri: &Uri,
locations: Vec<Location>,
) -> CodeActionResponse {
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,
command: "pyrefly.findFileUsages".to_owned(),
arguments: Some(vec![json!({
"uri": uri,
"locations": locations,
})]),
}),
..Default::default()
})
}

fn collect_import_usages(
transaction: &Transaction<'_>,
rdeps: HashSet<Handle>,
target_module: ModuleName,
) -> Vec<TextRangeWithModule> {
let mut rdeps = rdeps.into_iter().collect::<Vec<_>>();
// Process an open buffer before its filesystem handle reaches path deduplication.
rdeps.sort_by_key(|handle| !handle.path().is_memory());
let mut seen = HashSet::new();
let mut usages = Vec::new();
for rdep_handle in rdeps {
if !seen.insert(rdep_handle.path().as_path().to_owned()) {
continue;
}
let Some(module_info) = transaction.get_module_info(&rdep_handle) else {
continue;
};
if !matches!(
module_info.path().details(),
ModulePathDetails::FileSystem(_) | ModulePathDetails::Memory(_)
) {
continue;
}
// Indexed closed files retain source text, but they do not always retain an AST.
let ast = 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(),
ranges: Vec::new(),
};
for stmt in &ast.body {
visitor.visit_stmt(stmt);
}
if visitor.ranges.is_empty() {
continue;
}
for range in visitor.ranges {
usages.push(TextRangeWithModule::new(module_info.clone(), range));
}
}
usages
}

struct ImportUsageVisitor<'a> {
transaction: &'a Transaction<'a>,
importer: &'a Handle,
target_module: ModuleName,
current_module: ModuleName,
is_init: bool,
ranges: Vec<TextRange>,
}

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);
return true;
}
false
}

fn visit_import(&mut self, import: &StmtImport) {
for alias in &import.names {
let imported_module = ModuleName::from_name(&alias.name.id);
if self.record_import(imported_module, import.range()) {
return;
}
}
}

fn visit_import_from(&mut self, import_from: &StmtImportFrom) {
let base = self.current_module.new_maybe_relative(
self.is_init,
import_from.level,
import_from.module.as_ref().map(|id| &id.id),
);
let Some(base) = base else {
return;
};
if self.record_import(base, import_from.range()) {
return;
}
let base_exports = self
.transaction
.import_handle(self.importer, base, None)
.finding()
.map(|handle| self.transaction.get_exports(&handle));
for alias in &import_from.names {
let imported_module = append_module(base, &alias.name.id);
let refers_to_target = match base_exports
.as_ref()
.and_then(|exports| exports.get(&alias.name.id))
{
Some(ExportLocation::ThisModule(_)) => false,
Some(ExportLocation::OtherModule(module, _)) => {
module_matches_target(*module, self.target_module)
}
None => module_matches_target(imported_module, self.target_module),
};
if refers_to_target {
self.ranges.push(import_from.range());
return;
}
}
}
}

impl Visitor<'_> for ImportUsageVisitor<'_> {
fn visit_stmt(&mut self, stmt: &Stmt) {
match stmt {
Stmt::Import(import) => self.visit_import(import),
Stmt::ImportFrom(import_from) => self.visit_import_from(import_from),
_ => walk_stmt(self, stmt),
}
}
}

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;
}
let module_str = module.as_str();
let target_str = target.as_str();
module_str.starts_with(target_str)
&& module_str.len() > target_str.len()
&& module_str.as_bytes().get(target_str.len()) == Some(&b'.')
}
16 changes: 13 additions & 3 deletions pyrefly/lib/lsp/non_wasm/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ 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::queue::QueuedEvent;
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::should_show_stdlib_error;
use crate::lsp::non_wasm::transaction_manager::TransactionManager;
use crate::lsp::non_wasm::type_error_display_status::BuildSystemStatus;
Expand Down Expand Up @@ -5480,13 +5480,23 @@ impl Server {
}
record_code_action_telemetry("move_symbol_new_file", start);
let start = Instant::now();
if let Some(action) = safe_delete_file_code_action(
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,
|location| self.to_lsp_location(location),
) {
actions.push(action);
actions.append(&mut file_actions);
}
record_code_action_telemetry("safe_delete_file", start);
}
Expand Down
Loading
Loading