use std::collections::{HashMap, HashSet};
use tower_lsp::lsp_types::*;
use super::{CodeActionData, make_code_action_data};
use crate::Backend;
use crate::util::{offset_to_position, ranges_overlap};
impl Backend {
pub(crate) fn collect_remove_unused_import_actions(
&self,
uri: &str,
content: &str,
params: &CodeActionParams,
out: &mut Vec<CodeActionOrCommand>,
) {
let mut all_unused_diags: Vec<Diagnostic> = Vec::new();
self.collect_unused_import_diagnostics(uri, content, &mut all_unused_diags);
if all_unused_diags.is_empty() {
return;
}
let overlapping: Vec<&Diagnostic> = all_unused_diags
.iter()
.filter(|d| ranges_overlap(&d.range, ¶ms.range))
.collect();
for diag in &overlapping {
let title = format!(
"Remove {}",
diag.message
.strip_prefix("Unused import ")
.map(|rest| format!("unused import {rest}"))
.unwrap_or_else(|| "unused import".to_string())
);
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title,
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![(*diag).clone()]),
edit: None,
command: None,
is_preferred: Some(true),
disabled: None,
data: Some(make_code_action_data(
"quickfix.removeUnusedImport",
uri,
¶ms.range,
serde_json::json!({}),
)),
}));
}
if !all_unused_diags.is_empty()
&& cursor_on_use_import_line(content, params.range.start.line)
{
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title: "Remove all unused imports".to_string(),
kind: Some(CodeActionKind::new("source.organizeImports")),
diagnostics: Some(all_unused_diags),
edit: None,
command: None,
is_preferred: None,
disabled: None,
data: Some(make_code_action_data(
"quickfix.removeAllUnusedImports",
uri,
¶ms.range,
serde_json::json!({}),
)),
}));
}
}
pub(crate) fn resolve_remove_unused_import(
&self,
data: &CodeActionData,
content: &str,
diagnostics: Option<&[Diagnostic]>,
) -> Option<WorkspaceEdit> {
let doc_uri: Url = data.uri.parse().ok()?;
let diags = diagnostics?;
if diags.is_empty() {
return None;
}
let is_bulk = data.action_kind == "quickfix.removeAllUnusedImports";
if is_bulk {
let mut fresh_diags: Vec<Diagnostic> = Vec::new();
self.collect_unused_import_diagnostics(&data.uri, content, &mut fresh_diags);
if fresh_diags.is_empty() {
return None;
}
let removed_import_lines: HashSet<usize> = fresh_diags
.iter()
.map(|d| d.range.start.line as usize)
.collect();
let mut edits: Vec<TextEdit> = fresh_diags
.iter()
.map(|d| build_line_deletion_edit(content, &d.range, &removed_import_lines))
.collect();
edits.sort_by(|a, b| b.range.start.cmp(&a.range.start));
let mut changes = HashMap::new();
changes.insert(doc_uri, edits);
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
} else {
let diag = &diags[0];
let removed_import_lines = HashSet::from([diag.range.start.line as usize]);
let removal_edit =
build_line_deletion_edit(content, &diag.range, &removed_import_lines);
let mut changes = HashMap::new();
changes.insert(doc_uri, vec![removal_edit]);
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
}
}
fn cursor_on_use_import_line(content: &str, line: u32) -> bool {
let lines: Vec<&str> = content.lines().collect();
let idx = line as usize;
if idx >= lines.len() {
return false;
}
let trimmed = lines[idx].trim();
if !trimmed.starts_with("use ") {
return false;
}
let mut depth: i32 = 0;
for l in &lines[..idx] {
for ch in l.chars() {
match ch {
'{' => depth += 1,
'}' => depth -= 1,
_ => {}
}
}
}
depth <= 0
}
pub(crate) fn build_line_deletion_edit(
content: &str,
range: &Range,
removed_import_lines: &HashSet<usize>,
) -> TextEdit {
if let Some(edit) = extend_range_for_group_member(content, range) {
return edit;
}
let lines: Vec<&str> = content.lines().collect();
let start_line = range.start.line as usize;
let end_line = range.end.line as usize;
let mut start_offset = 0;
for line in &lines[..start_line] {
start_offset += line.len() + 1; }
let mut edit_start_offset = start_offset;
if should_consume_previous_blank_line(
lines.as_slice(),
start_line,
end_line,
removed_import_lines,
) {
edit_start_offset -= lines[start_line - 1].len() + 1;
}
let mut end_offset = start_offset;
for line in &lines[start_line..=end_line.min(lines.len() - 1)] {
end_offset += line.len() + 1;
}
if should_consume_following_blank_line(
lines.as_slice(),
start_line,
end_line,
removed_import_lines,
) {
end_offset += lines[end_line + 1].len() + 1;
}
end_offset = end_offset.min(content.len());
let start_pos = offset_to_position(content, edit_start_offset);
let end_pos = offset_to_position(content, end_offset);
TextEdit {
range: Range {
start: start_pos,
end: end_pos,
},
new_text: String::new(),
}
}
pub(crate) fn should_consume_following_blank_line(
lines: &[&str],
start_line: usize,
end_line: usize,
removed_import_lines: &HashSet<usize>,
) -> bool {
if !matches!(lines.get(end_line + 1), Some(line) if line.trim().is_empty()) {
return false;
}
nearest_surviving_import_line(lines, end_line as isize + 2, 1, removed_import_lines).is_some()
|| nearest_surviving_import_line(lines, start_line as isize - 1, -1, removed_import_lines)
.is_none()
}
pub(crate) fn should_consume_previous_blank_line(
lines: &[&str],
start_line: usize,
end_line: usize,
removed_import_lines: &HashSet<usize>,
) -> bool {
if start_line == 0 {
return false;
}
if !matches!(lines.get(start_line - 1), Some(line) if line.trim().is_empty()) {
return false;
}
nearest_surviving_import_line(lines, start_line as isize - 2, -1, removed_import_lines)
.is_some()
&& nearest_surviving_import_line(lines, end_line as isize + 1, 1, removed_import_lines)
.is_some()
}
pub(crate) fn nearest_surviving_import_line(
lines: &[&str],
mut line: isize,
direction: isize,
removed_import_lines: &HashSet<usize>,
) -> Option<usize> {
while let Some(current) = usize::try_from(line).ok().and_then(|idx| lines.get(idx)) {
let trimmed = current.trim();
if trimmed.is_empty() {
line += direction;
continue;
}
if trimmed.starts_with("use ") {
let idx = usize::try_from(line).ok()?;
if !removed_import_lines.contains(&idx) {
return Some(idx);
}
line += direction;
continue;
}
return None;
}
None
}
pub(crate) fn extend_range_for_group_member(content: &str, range: &Range) -> Option<TextEdit> {
let lines: Vec<&str> = content.lines().collect();
let line_idx = range.start.line as usize;
if line_idx >= lines.len() {
return None;
}
let line = lines[line_idx];
let full_stmt = if line.contains('{') && line.contains('}') {
line.to_string()
} else {
let mut start = line_idx;
while start > 0 && !lines[start].trim_start().starts_with("use ") {
start -= 1;
}
if !lines[start].contains('{') {
return None;
}
let mut end = line_idx;
while end < lines.len() && !lines[end].contains('}') {
end += 1;
}
if end >= lines.len() {
return None;
}
lines[start..=end].join("\n")
};
if !full_stmt.contains('{') || !full_stmt.contains('}') {
return None;
}
let start_col = range.start.character as usize;
let end_col = range.end.character as usize;
if end_col > line.len() || start_col >= end_col {
return None;
}
let member_text = &line[start_col..end_col];
let member_start_in_line = start_col;
let after_member = &line[end_col..];
let (removal_end, _has_trailing_comma) = if let Some(rest) = after_member.strip_prefix(',') {
let skip = 1 + rest.len() - rest.trim_start().len();
(end_col + skip, true)
} else {
(end_col, false)
};
let before_member = &line[..member_start_in_line];
let removal_start = if removal_end == end_col {
let trimmed = before_member.trim_end();
if trimmed.ends_with(',') {
trimmed.len() - 1
} else {
member_start_in_line
}
} else {
member_start_in_line
};
let brace_start = full_stmt.find('{')?;
let brace_end = full_stmt.find('}')?;
let members_text = &full_stmt[brace_start + 1..brace_end];
let member_count = members_text
.split(',')
.filter(|m| !m.trim().is_empty())
.count();
if member_count <= 1 {
return None; }
if member_text.trim().is_empty() {
return None;
}
let start_pos = Position::new(range.start.line, removal_start as u32);
let end_pos = Position::new(range.start.line, removal_end as u32);
Some(TextEdit {
range: Range {
start: start_pos,
end: end_pos,
},
new_text: String::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::position_to_byte_offset;
fn lsp_position_to_byte_offset(content: &str, pos: Position) -> usize {
position_to_byte_offset(content, pos)
}
fn build_single_line_deletion_edit(content: &str, range: &Range) -> TextEdit {
let removed = HashSet::from([range.start.line as usize]);
build_line_deletion_edit(content, range, &removed)
}
#[test]
fn overlapping_ranges() {
let a = Range::new(Position::new(1, 0), Position::new(1, 10));
let b = Range::new(Position::new(1, 5), Position::new(1, 15));
assert!(ranges_overlap(&a, &b));
}
#[test]
fn non_overlapping_ranges() {
let a = Range::new(Position::new(1, 0), Position::new(1, 5));
let b = Range::new(Position::new(2, 0), Position::new(2, 5));
assert!(!ranges_overlap(&a, &b));
}
#[test]
fn touching_ranges_do_not_overlap() {
let a = Range::new(Position::new(1, 0), Position::new(1, 5));
let b = Range::new(Position::new(1, 5), Position::new(1, 10));
assert!(!ranges_overlap(&a, &b));
}
#[test]
fn cursor_inside_range() {
let a = Range::new(Position::new(3, 0), Position::new(3, 20));
let b = Range::new(Position::new(3, 10), Position::new(3, 10)); assert!(ranges_overlap(&a, &b));
}
#[test]
fn deletes_full_use_line() {
let content = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\n";
let range = Range::new(Position::new(1, 4), Position::new(1, 11));
let edit = build_single_line_deletion_edit(content, &range);
let start = lsp_position_to_byte_offset(content, edit.range.start);
let end = lsp_position_to_byte_offset(content, edit.range.end);
assert_eq!(&content[start..end], "use Foo\\Bar;\n");
}
#[test]
fn deletes_use_line_and_separator_when_last_import_removed() {
let content = "<?php\nuse Foo\\Bar;\n\nclass Test {}\n";
let range = Range::new(Position::new(1, 4), Position::new(1, 11));
let edit = build_single_line_deletion_edit(content, &range);
let start = lsp_position_to_byte_offset(content, edit.range.start);
let end = lsp_position_to_byte_offset(content, edit.range.end);
assert_eq!(&content[start..end], "use Foo\\Bar;\n\n");
}
#[test]
fn keeps_separator_when_other_imports_remain() {
let content = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\n\nclass Test extends Qux {}\n";
let range = Range::new(Position::new(2, 4), Position::new(2, 11));
let edit = build_single_line_deletion_edit(content, &range);
let start = lsp_position_to_byte_offset(content, edit.range.start);
let end = lsp_position_to_byte_offset(content, edit.range.end);
assert_eq!(&content[start..end], "use Baz\\Qux;\n");
}
#[test]
fn removes_following_blank_line_between_remaining_imports() {
let content = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\n\nuse Quux\\Quuz;\n";
let range = Range::new(Position::new(2, 4), Position::new(2, 11));
let edit = build_single_line_deletion_edit(content, &range);
let start = lsp_position_to_byte_offset(content, edit.range.start);
let end = lsp_position_to_byte_offset(content, edit.range.end);
assert_eq!(&content[start..end], "use Baz\\Qux;\n\n");
}
#[test]
fn removes_previous_blank_line_between_remaining_imports() {
let content = "<?php\nuse Foo\\Bar;\n\nuse Baz\\Qux;\nuse Quux\\Quuz;\n";
let range = Range::new(Position::new(3, 4), Position::new(3, 11));
let edit = build_single_line_deletion_edit(content, &range);
let start = lsp_position_to_byte_offset(content, edit.range.start);
let end = lsp_position_to_byte_offset(content, edit.range.end);
let mut result = content.to_string();
result.replace_range(start..end, &edit.new_text);
assert_eq!(result, "<?php\nuse Foo\\Bar;\nuse Quux\\Quuz;\n");
}
#[test]
fn deletes_partial_group_member_trailing_comma() {
let content = "<?php\nuse Foo\\{Bar, Baz, Qux};\n";
let range = Range::new(Position::new(1, 9), Position::new(1, 12));
let edit = extend_range_for_group_member(content, &range);
assert!(edit.is_some(), "should produce a group member edit");
let edit = edit.unwrap();
assert_eq!(edit.new_text, "");
}
#[test]
fn remove_action_offered_for_unused_import() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\n\nclass Test extends Qux {}\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(1, 4),
end: Position::new(1, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let remove_action = actions.iter().find(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.starts_with("Remove unused import"),
_ => false,
});
assert!(
remove_action.is_some(),
"should offer 'Remove unused import' action"
);
}
#[test]
fn no_remove_action_for_used_import() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nuse Foo\\Bar;\n\nclass Test extends Bar {}\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(1, 4),
end: Position::new(1, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let remove_action = actions.iter().find(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.starts_with("Remove unused import"),
_ => false,
});
assert!(
remove_action.is_none(),
"should NOT offer remove action for used import"
);
}
#[test]
fn bulk_remove_offered_when_multiple_unused() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(1, 4),
end: Position::new(1, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let bulk = actions.iter().find(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title == "Remove all unused imports",
_ => false,
});
assert!(
bulk.is_some(),
"should offer 'Remove all unused imports' when multiple unused"
);
}
#[test]
fn bulk_remove_offered_for_single_unused_import() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nuse Foo\\Bar;\n\nclass Test {}\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(1, 4),
end: Position::new(1, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let bulk = actions.iter().find(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title == "Remove all unused imports",
_ => false,
});
assert!(
bulk.is_some(),
"should offer 'Remove all unused imports' even for a single unused import"
);
}
#[test]
fn bulk_remove_not_offered_when_cursor_outside_import_block() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nuse Foo\\Bar;\n\nclass Test {}\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(3, 0),
end: Position::new(3, 0),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let bulk = actions.iter().find(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title == "Remove all unused imports",
_ => false,
});
let single = actions.iter().find(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.starts_with("Remove unused import"),
_ => false,
});
assert!(
bulk.is_none(),
"should NOT offer bulk remove when cursor is not on a use line"
);
assert!(
single.is_none(),
"should NOT offer single remove when cursor is not on the unused import"
);
}
#[test]
fn bulk_remove_offered_when_cursor_on_used_import() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\n\nclass Test extends Qux {}\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 4),
end: Position::new(2, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let bulk = actions.iter().find(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title == "Remove all unused imports",
_ => false,
});
assert!(
bulk.is_some(),
"should offer bulk remove when cursor is on any use line"
);
}
#[test]
fn cursor_on_use_line_returns_true() {
let content = "<?php\nuse Foo\\Bar;\nclass Test {}\n";
assert!(cursor_on_use_import_line(content, 1));
}
#[test]
fn cursor_on_non_use_line_returns_false() {
let content = "<?php\nuse Foo\\Bar;\nclass Test {\n public function foo() {}\n}\n";
assert!(!cursor_on_use_import_line(content, 2)); assert!(!cursor_on_use_import_line(content, 3)); }
#[test]
fn cursor_on_trait_use_returns_false() {
let content = "<?php\nclass Foo {\n use SomeTrait;\n}\n";
assert!(!cursor_on_use_import_line(content, 2));
}
#[test]
fn cursor_on_use_in_braced_namespace_returns_true() {
let content = "<?php\nnamespace App {\n use Foo\\Bar;\n}\n";
assert!(!cursor_on_use_import_line(content, 2));
}
#[test]
fn bulk_remove_deletes_both_widely_separated_unused_imports() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "\
<?php
use App\\UnusedA;
use App\\UsedB;
class Foo extends UsedB
{
public function bar(): void
{
// some code
}
}
use App\\UnusedC;
";
backend.update_ast(uri, content);
backend
.open_files
.write()
.insert(uri.to_string(), std::sync::Arc::new(content.to_string()));
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 4),
end: Position::new(2, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let bulk = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title == "Remove all unused imports" => {
Some(ca)
}
_ => None,
})
.expect("should offer bulk remove");
assert!(bulk.edit.is_none(), "Phase 1 should not have an edit");
assert!(bulk.data.is_some(), "Phase 1 should have data");
let (resolved, _) = backend.resolve_code_action(bulk.clone());
let edit = resolved
.edit
.as_ref()
.expect("resolve should produce an edit");
let changes = edit.changes.as_ref().unwrap();
let edits: Vec<&TextEdit> = changes.values().flat_map(|v| v.iter()).collect();
assert!(
edits.len() >= 2,
"should delete both unused imports, got {} edits",
edits.len()
);
}
#[test]
fn bulk_remove_in_braced_namespace_with_class_bodies_between() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "\
<?php
use App\\UnusedAlpha;
use App\\UsedBravo;
use App\\UnusedCharlie;
class Demo extends UsedBravo
{
public function method(): void
{
}
}
";
backend.update_ast(uri, content);
backend
.open_files
.write()
.insert(uri.to_string(), std::sync::Arc::new(content.to_string()));
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(1, 4),
end: Position::new(1, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let bulk = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title == "Remove all unused imports" => {
Some(ca)
}
_ => None,
})
.expect("should offer bulk remove");
let (resolved, _) = backend.resolve_code_action(bulk.clone());
let edit = resolved
.edit
.as_ref()
.expect("resolve should produce an edit");
let changes = edit.changes.as_ref().unwrap();
let edits: Vec<&TextEdit> = changes.values().flat_map(|v| v.iter()).collect();
assert!(
edits.len() >= 2,
"should delete both unused imports, got {} edits",
edits.len()
);
let mut result = content.to_string();
let mut sorted: Vec<&TextEdit> = edits.clone();
sorted.sort_by(|a, b| {
b.range
.start
.line
.cmp(&a.range.start.line)
.then(b.range.start.character.cmp(&a.range.start.character))
});
for edit in sorted {
let start = lsp_position_to_byte_offset(&result, edit.range.start);
let end = lsp_position_to_byte_offset(&result, edit.range.end);
result.replace_range(start..end, &edit.new_text);
}
assert!(
!result.contains("UnusedAlpha"),
"UnusedAlpha should be removed:\n{result}"
);
assert!(
!result.contains("UnusedCharlie"),
"UnusedCharlie should be removed:\n{result}"
);
assert!(
result.contains("UsedBravo"),
"UsedBravo should be kept:\n{result}"
);
}
#[test]
fn bulk_remove_consumes_separator_when_import_block_becomes_empty() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\n\nclass Test {}\n";
backend.update_ast(uri, content);
backend
.open_files
.write()
.insert(uri.to_string(), std::sync::Arc::new(content.to_string()));
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(1, 4),
end: Position::new(1, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let bulk = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title == "Remove all unused imports" => {
Some(ca)
}
_ => None,
})
.expect("should offer bulk remove");
let (resolved, _) = backend.resolve_code_action(bulk.clone());
let edit = resolved
.edit
.as_ref()
.expect("resolve should produce an edit");
let changes = edit.changes.as_ref().unwrap();
let edits: Vec<&TextEdit> = changes.values().flat_map(|v| v.iter()).collect();
let mut result = content.to_string();
let mut sorted: Vec<&TextEdit> = edits.clone();
sorted.sort_by(|a, b| {
b.range
.start
.line
.cmp(&a.range.start.line)
.then(b.range.start.character.cmp(&a.range.start.character))
});
for edit in sorted {
let start = lsp_position_to_byte_offset(&result, edit.range.start);
let end = lsp_position_to_byte_offset(&result, edit.range.end);
result.replace_range(start..end, &edit.new_text);
}
assert_eq!(result, "<?php\nclass Test {}\n");
}
#[test]
fn bulk_remove_collapses_gap_when_unused_import_is_between_used_ones() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\n\nuse Quux\\Quuz;\n\nclass Test extends Bar\n{\n public function make(): Quuz\n {\n return new Quuz();\n }\n}\n";
backend.update_ast(uri, content);
backend
.open_files
.write()
.insert(uri.to_string(), std::sync::Arc::new(content.to_string()));
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 4),
end: Position::new(2, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let bulk = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title == "Remove all unused imports" => {
Some(ca)
}
_ => None,
})
.expect("should offer bulk remove");
let (resolved, _) = backend.resolve_code_action(bulk.clone());
let edit = resolved
.edit
.as_ref()
.expect("resolve should produce an edit");
let changes = edit.changes.as_ref().unwrap();
let edits: Vec<&TextEdit> = changes.values().flat_map(|v| v.iter()).collect();
let mut result = content.to_string();
let mut sorted: Vec<&TextEdit> = edits.clone();
sorted.sort_by(|a, b| {
b.range
.start
.line
.cmp(&a.range.start.line)
.then(b.range.start.character.cmp(&a.range.start.character))
});
for edit in sorted {
let start = lsp_position_to_byte_offset(&result, edit.range.start);
let end = lsp_position_to_byte_offset(&result, edit.range.end);
result.replace_range(start..end, &edit.new_text);
}
assert_eq!(
result,
"<?php\nuse Foo\\Bar;\nuse Quux\\Quuz;\n\nclass Test extends Bar\n{\n public function make(): Quuz\n {\n return new Quuz();\n }\n}\n"
);
}
#[test]
fn removing_middle_import_from_contiguous_block_leaves_no_blank_line() {
let content = "\
<?php
use PHPMD\\Node\\AbstractCallableNode;
use PHPMD\\Node\\MethodNode;
use PHPMD\\Rule;
use PHPMD\\Rule\\Design\\CouplingBetweenObjects;
";
let removed = HashSet::from([3usize]);
let range = Range::new(Position::new(3, 4), Position::new(3, 14));
let edit = build_line_deletion_edit(content, &range, &removed);
let start = lsp_position_to_byte_offset(content, edit.range.start);
let end = lsp_position_to_byte_offset(content, edit.range.end);
let mut result = content.to_string();
result.replace_range(start..end, &edit.new_text);
assert_eq!(
result,
"\
<?php
use PHPMD\\Node\\AbstractCallableNode;
use PHPMD\\Node\\MethodNode;
use PHPMD\\Rule\\Design\\CouplingBetweenObjects;
",
"Removing a middle import should not leave a blank line"
);
}
#[test]
fn removing_first_import_from_contiguous_block_leaves_no_blank_line() {
let content = "\
<?php
use PHPMD\\Node\\AbstractCallableNode;
use PHPMD\\Node\\MethodNode;
use PHPMD\\Rule;
";
let removed = HashSet::from([1usize]);
let range = Range::new(Position::new(1, 4), Position::new(1, 34));
let edit = build_line_deletion_edit(content, &range, &removed);
let start = lsp_position_to_byte_offset(content, edit.range.start);
let end = lsp_position_to_byte_offset(content, edit.range.end);
let mut result = content.to_string();
result.replace_range(start..end, &edit.new_text);
assert_eq!(
result,
"\
<?php
use PHPMD\\Node\\MethodNode;
use PHPMD\\Rule;
",
"Removing the first import should not leave a blank line"
);
}
}