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
191 changes: 191 additions & 0 deletions pyrefly/lib/lsp/non_wasm/convert_module_package.rs
Original file line number Diff line number Diff line change
@@ -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<CodeActionOrCommand> {
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
}
1 change: 1 addition & 0 deletions pyrefly/lib/lsp/non_wasm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions pyrefly/lib/lsp/non_wasm/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
163 changes: 163 additions & 0 deletions pyrefly/lib/test/lsp/lsp_interaction/convert_module_package.rs
Original file line number Diff line number Diff line change
@@ -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::<CodeActionRequest>(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::<CodeActionRequest>(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();
}
1 change: 1 addition & 0 deletions pyrefly/lib/test/lsp/lsp_interaction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod basic;
mod call_hierarchy;
mod completion;
mod configuration;
mod convert_module_package;
mod definition;
mod diagnostic;
mod did_change;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VALUE = 1
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
value = 42
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
search_path = ["."]
Loading