diff --git a/pyrefly/lib/lsp/non_wasm/convert_module_package.rs b/pyrefly/lib/lsp/non_wasm/convert_module_package.rs new file mode 100644 index 0000000000..44d32329b3 --- /dev/null +++ b/pyrefly/lib/lsp/non_wasm/convert_module_package.rs @@ -0,0 +1,191 @@ +/* + * 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. + */ + +use std::ffi::OsStr; +use std::fs; +use std::path::Path; + +use lsp_types::ClientCapabilities; +use lsp_types::CodeAction; +use lsp_types::CodeActionKind; +use lsp_types::CodeActionOrCommand; +use lsp_types::DeleteFile; +use lsp_types::DeleteFileOptions; +use lsp_types::DocumentChangeOperation; +use lsp_types::DocumentChanges; +use lsp_types::RenameFile; +use lsp_types::ResourceOp; +use lsp_types::ResourceOperationKind; +use lsp_types::Url; +use lsp_types::WorkspaceEdit; + +fn supports_workspace_edit_document_changes(capabilities: &ClientCapabilities) -> bool { + capabilities + .workspace + .as_ref() + .and_then(|workspace| workspace.workspace_edit.as_ref()) + .and_then(|workspace_edit| workspace_edit.document_changes) + .unwrap_or(false) +} + +fn supports_workspace_edit_resource_ops( + capabilities: &ClientCapabilities, + required: &[ResourceOperationKind], +) -> bool { + let supported = capabilities + .workspace + .as_ref() + .and_then(|workspace| workspace.workspace_edit.as_ref()) + .and_then(|workspace_edit| workspace_edit.resource_operations.as_ref()); + required + .iter() + .all(|kind| supported.is_some_and(|ops| ops.contains(kind))) +} + +fn package_dir_is_empty(dir: &Path, init_file: &OsStr) -> bool { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return false, + }; + for entry in entries.flatten() { + let name = entry.file_name(); + if name == init_file || name == OsStr::new("__pycache__") { + continue; + } + return false; + } + true +} + +pub(crate) fn convert_module_package_code_actions( + capabilities: &ClientCapabilities, + uri: &Url, +) -> Vec { + if !supports_workspace_edit_document_changes(capabilities) { + return Vec::new(); + } + let path = match uri.to_file_path() { + Ok(path) => path, + Err(_) => return Vec::new(), + }; + let extension = match path.extension().and_then(|ext| ext.to_str()) { + Some(ext @ "py") | Some(ext @ "pyi") => ext, + _ => return Vec::new(), + }; + if !path.is_file() { + return Vec::new(); + } + let Some(file_stem) = path.file_stem().and_then(|stem| stem.to_str()) else { + return Vec::new(); + }; + let mut actions = Vec::new(); + if file_stem == "__init__" { + if !supports_workspace_edit_resource_ops( + capabilities, + &[ResourceOperationKind::Rename, ResourceOperationKind::Delete], + ) { + return actions; + } + let Some(package_dir) = path.parent() else { + return actions; + }; + let Some(package_name) = package_dir.file_name().and_then(|name| name.to_str()) else { + return actions; + }; + let Some(parent_dir) = package_dir.parent() else { + return actions; + }; + let new_path = parent_dir.join(format!("{package_name}.{extension}")); + if new_path.exists() { + return actions; + } + let Some(init_name) = path.file_name() else { + return actions; + }; + if !package_dir_is_empty(package_dir, init_name) { + return actions; + } + let old_uri = match Url::from_file_path(&path) { + Ok(uri) => uri, + Err(_) => return actions, + }; + let new_uri = match Url::from_file_path(&new_path) { + Ok(uri) => uri, + Err(_) => return actions, + }; + let package_uri = match Url::from_file_path(package_dir) { + Ok(uri) => uri, + Err(_) => return actions, + }; + let operations = vec![ + DocumentChangeOperation::Op(ResourceOp::Rename(RenameFile { + old_uri, + new_uri, + options: None, + annotation_id: None, + })), + DocumentChangeOperation::Op(ResourceOp::Delete(DeleteFile { + uri: package_uri, + options: Some(DeleteFileOptions { + recursive: Some(true), + ignore_if_not_exists: Some(true), + annotation_id: None, + }), + })), + ]; + actions.push(CodeActionOrCommand::CodeAction(CodeAction { + title: "Convert package to module".to_owned(), + kind: Some(CodeActionKind::new("refactor.move")), + edit: Some(WorkspaceEdit { + document_changes: Some(DocumentChanges::Operations(operations)), + ..Default::default() + }), + ..Default::default() + })); + } else { + if !supports_workspace_edit_resource_ops(capabilities, &[ResourceOperationKind::Rename]) { + return actions; + } + let Some(parent_dir) = path.parent() else { + return actions; + }; + let package_dir = parent_dir.join(file_stem); + if package_dir.exists() { + return actions; + } + let new_path = package_dir.join(format!("__init__.{extension}")); + if new_path.exists() { + return actions; + } + let old_uri = match Url::from_file_path(&path) { + Ok(uri) => uri, + Err(_) => return actions, + }; + let new_uri = match Url::from_file_path(&new_path) { + Ok(uri) => uri, + Err(_) => return actions, + }; + let operations = vec![DocumentChangeOperation::Op(ResourceOp::Rename( + RenameFile { + old_uri, + new_uri, + options: None, + annotation_id: None, + }, + ))]; + actions.push(CodeActionOrCommand::CodeAction(CodeAction { + title: "Convert module to package".to_owned(), + kind: Some(CodeActionKind::new("refactor.move")), + edit: Some(WorkspaceEdit { + document_changes: Some(DocumentChanges::Operations(operations)), + ..Default::default() + }), + ..Default::default() + })); + } + actions +} diff --git a/pyrefly/lib/lsp/non_wasm/mod.rs b/pyrefly/lib/lsp/non_wasm/mod.rs index d5bd402c8a..579837013f 100644 --- a/pyrefly/lib/lsp/non_wasm/mod.rs +++ b/pyrefly/lib/lsp/non_wasm/mod.rs @@ -7,6 +7,7 @@ mod build_system; pub mod call_hierarchy; +pub mod convert_module_package; pub mod document_symbols; pub mod folding_ranges; pub mod lsp; diff --git a/pyrefly/lib/lsp/non_wasm/server.rs b/pyrefly/lib/lsp/non_wasm/server.rs index 0c6cdacd25..1582113e4d 100644 --- a/pyrefly/lib/lsp/non_wasm/server.rs +++ b/pyrefly/lib/lsp/non_wasm/server.rs @@ -220,6 +220,7 @@ use crate::lsp::non_wasm::call_hierarchy::find_function_at_position_in_ast; use crate::lsp::non_wasm::call_hierarchy::prepare_call_hierarchy_item; use crate::lsp::non_wasm::call_hierarchy::transform_incoming_calls; use crate::lsp::non_wasm::call_hierarchy::transform_outgoing_calls; +use crate::lsp::non_wasm::convert_module_package::convert_module_package_code_actions; use crate::lsp::non_wasm::lsp::apply_change_events; use crate::lsp::non_wasm::lsp::as_notification; use crate::lsp::non_wasm::lsp::as_request; @@ -2938,6 +2939,10 @@ impl Server { { push_refactor_actions(refactors); } + actions.extend(convert_module_package_code_actions( + &self.initialize_params.capabilities, + uri, + )); if actions.is_empty() { None } else { diff --git a/pyrefly/lib/test/lsp/lsp_interaction/convert_module_package.rs b/pyrefly/lib/test/lsp/lsp_interaction/convert_module_package.rs new file mode 100644 index 0000000000..179813579a --- /dev/null +++ b/pyrefly/lib/test/lsp/lsp_interaction/convert_module_package.rs @@ -0,0 +1,163 @@ +/* + * 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. + */ + +use lsp_types::CodeActionOrCommand; +use lsp_types::DocumentChangeOperation; +use lsp_types::DocumentChanges; +use lsp_types::ResourceOp; +use lsp_types::Url; +use lsp_types::request::CodeActionRequest; +use serde_json::json; + +use crate::test::lsp::lsp_interaction::object_model::InitializeSettings; +use crate::test::lsp::lsp_interaction::object_model::LspInteraction; +use crate::test::lsp::lsp_interaction::util::get_test_files_root; + +fn init_with_workspace_edit_support(root_path: &std::path::Path) -> (LspInteraction, Url) { + let scope_uri = Url::from_file_path(root_path).unwrap(); + let mut interaction = LspInteraction::new(); + interaction.set_root(root_path.to_path_buf()); + interaction + .initialize(InitializeSettings { + workspace_folders: Some(vec![("test".to_owned(), scope_uri.clone())]), + capabilities: Some(json!({ + "workspace": { + "workspaceEdit": { + "documentChanges": true, + "resourceOperations": ["rename", "delete"] + } + } + })), + ..Default::default() + }) + .unwrap(); + (interaction, scope_uri) +} + +#[test] +fn test_convert_module_to_package_code_action() { + let root = get_test_files_root(); + let root_path = root.path().join("convert_module_package"); + let (interaction, _scope_uri) = init_with_workspace_edit_support(&root_path); + + let file = "foo.py"; + let file_path = root_path.join(file); + let uri = Url::from_file_path(&file_path).unwrap(); + + interaction.client.did_open(file); + + let expected_old = Url::from_file_path(&file_path).unwrap(); + let expected_new = Url::from_file_path(root_path.join("foo/__init__.py")).unwrap(); + + 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| { + let Some(actions) = response else { + return false; + }; + actions.iter().any(|action| { + let CodeActionOrCommand::CodeAction(code_action) = action else { + return false; + }; + if code_action.title != "Convert module to package" { + return false; + } + let Some(edit) = &code_action.edit else { + return false; + }; + let Some(DocumentChanges::Operations(ops)) = &edit.document_changes else { + return false; + }; + if ops.len() != 1 { + return false; + } + match &ops[0] { + DocumentChangeOperation::Op(ResourceOp::Rename(rename)) => { + rename.old_uri == expected_old && rename.new_uri == expected_new + } + _ => false, + } + }) + }) + .unwrap(); + + interaction.shutdown().unwrap(); +} + +#[test] +fn test_convert_package_to_module_code_action() { + let root = get_test_files_root(); + let root_path = root.path().join("convert_module_package"); + let (interaction, _scope_uri) = init_with_workspace_edit_support(&root_path); + + let file = "empty_pkg/__init__.py"; + let file_path = root_path.join(file); + let uri = Url::from_file_path(&file_path).unwrap(); + + interaction.client.did_open(file); + + let expected_old = Url::from_file_path(&file_path).unwrap(); + let expected_new = Url::from_file_path(root_path.join("empty_pkg.py")).unwrap(); + let expected_delete = Url::from_file_path(root_path.join("empty_pkg")).unwrap(); + + 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| { + let Some(actions) = response else { + return false; + }; + actions.iter().any(|action| { + let CodeActionOrCommand::CodeAction(code_action) = action else { + return false; + }; + if code_action.title != "Convert package to module" { + return false; + } + let Some(edit) = &code_action.edit else { + return false; + }; + let Some(DocumentChanges::Operations(ops)) = &edit.document_changes else { + return false; + }; + if ops.len() != 2 { + return false; + } + let rename_ok = match &ops[0] { + DocumentChangeOperation::Op(ResourceOp::Rename(rename)) => { + rename.old_uri == expected_old && rename.new_uri == expected_new + } + _ => false, + }; + let delete_ok = match &ops[1] { + DocumentChangeOperation::Op(ResourceOp::Delete(delete)) => { + delete.uri == expected_delete + } + _ => false, + }; + rename_ok && delete_ok + }) + }) + .unwrap(); + + interaction.shutdown().unwrap(); +} diff --git a/pyrefly/lib/test/lsp/lsp_interaction/mod.rs b/pyrefly/lib/test/lsp/lsp_interaction/mod.rs index 0112605f2f..8961f0834c 100644 --- a/pyrefly/lib/test/lsp/lsp_interaction/mod.rs +++ b/pyrefly/lib/test/lsp/lsp_interaction/mod.rs @@ -11,6 +11,7 @@ mod basic; mod call_hierarchy; mod completion; mod configuration; +mod convert_module_package; mod definition; mod diagnostic; mod did_change; diff --git a/pyrefly/lib/test/lsp/lsp_interaction/test_files/convert_module_package/empty_pkg/__init__.py b/pyrefly/lib/test/lsp/lsp_interaction/test_files/convert_module_package/empty_pkg/__init__.py new file mode 100644 index 0000000000..b15b1b0797 --- /dev/null +++ b/pyrefly/lib/test/lsp/lsp_interaction/test_files/convert_module_package/empty_pkg/__init__.py @@ -0,0 +1 @@ +VALUE = 1 diff --git a/pyrefly/lib/test/lsp/lsp_interaction/test_files/convert_module_package/foo.py b/pyrefly/lib/test/lsp/lsp_interaction/test_files/convert_module_package/foo.py new file mode 100644 index 0000000000..67f8b2c108 --- /dev/null +++ b/pyrefly/lib/test/lsp/lsp_interaction/test_files/convert_module_package/foo.py @@ -0,0 +1 @@ +value = 42 diff --git a/pyrefly/lib/test/lsp/lsp_interaction/test_files/convert_module_package/pyrefly.toml b/pyrefly/lib/test/lsp/lsp_interaction/test_files/convert_module_package/pyrefly.toml new file mode 100644 index 0000000000..54029c46ff --- /dev/null +++ b/pyrefly/lib/test/lsp/lsp_interaction/test_files/convert_module_package/pyrefly.toml @@ -0,0 +1 @@ +search_path = ["."]