use std::collections::HashMap;
use tower_lsp::lsp_types::{
CodeAction, CodeActionKind, CodeActionOrCommand, Position, Range, TextEdit, Url, WorkspaceEdit,
};
pub fn organize_imports_action(source: &str, uri: &Url) -> Option<CodeActionOrCommand> {
let block = find_use_block(source)?;
if block.statements.is_empty() {
return None;
}
let body_start_byte = block.body_start_byte;
let body = &source[body_start_byte..];
let mut kept: Vec<UseStatement> = block
.statements
.into_iter()
.filter(|u| is_used(u, body))
.collect();
if kept.is_empty() {
let edit = TextEdit {
range: block.range,
new_text: String::new(),
};
return Some(make_action(uri, edit));
}
kept.sort_by(|a, b| a.fqn.to_lowercase().cmp(&b.fqn.to_lowercase()));
let sorted_text: String = kept
.iter()
.map(|u| {
if let Some(alias) = &u.alias {
format!("use {} as {};\n", u.fqn, alias)
} else {
format!("use {};\n", u.fqn)
}
})
.collect();
let indent = block.indent.clone();
let indented: String = if indent.is_empty() {
sorted_text
} else {
sorted_text
.lines()
.map(|l| format!("{indent}{l}\n"))
.collect()
};
let current_text = &source[byte_range_of(source, block.range)];
if current_text == indented {
return None;
}
let edit = TextEdit {
range: block.range,
new_text: indented,
};
Some(make_action(uri, edit))
}
fn make_action(uri: &Url, edit: TextEdit) -> CodeActionOrCommand {
let mut changes = HashMap::new();
changes.insert(uri.clone(), vec![edit]);
CodeActionOrCommand::CodeAction(CodeAction {
title: "Organize imports".to_string(),
kind: Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
..Default::default()
})
}
#[derive(Debug, Clone)]
struct UseStatement {
fqn: String,
alias: Option<String>,
short: String,
}
struct UseBlock {
range: Range,
statements: Vec<UseStatement>,
indent: String,
body_start_byte: usize,
}
fn find_use_block(source: &str) -> Option<UseBlock> {
let mut first_line: Option<u32> = None;
let mut last_line: Option<u32> = None;
let mut statements: Vec<UseStatement> = Vec::new();
let mut indent = String::new();
for (idx, line) in source.lines().enumerate() {
let line_no = idx as u32;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some(rest) = trimmed.strip_prefix("use ") {
if rest.trim_start().starts_with('(') {
if first_line.is_some() {
break;
}
continue;
}
if rest.starts_with("function ") || rest.starts_with("const ") {
continue;
}
let stmt_text = rest.trim_end_matches(';').trim();
if let Some(us) = parse_use_statement(stmt_text) {
if first_line.is_none() {
first_line = Some(line_no);
indent = line
.chars()
.take_while(|c| c.is_whitespace())
.collect::<String>();
}
last_line = Some(line_no);
statements.push(us);
}
continue;
}
if first_line.is_some() {
break;
}
}
let first = first_line?;
let last = last_line?;
let range = Range {
start: Position {
line: first,
character: 0,
},
end: Position {
line: last + 1,
character: 0,
},
};
let body_start_byte = line_start_byte(source, last + 1);
Some(UseBlock {
range,
statements,
indent,
body_start_byte,
})
}
fn parse_use_statement(text: &str) -> Option<UseStatement> {
let (fqn_part, alias) = if let Some((fqn, al)) = text.split_once(" as ") {
(fqn.trim(), Some(al.trim().to_string()))
} else {
(text.trim(), None)
};
if fqn_part.is_empty() {
return None;
}
let short = match &alias {
Some(a) => a.clone(),
None => fqn_part.rsplit('\\').next().unwrap_or(fqn_part).to_string(),
};
Some(UseStatement {
fqn: fqn_part.to_string(),
alias,
short,
})
}
fn is_used(u: &UseStatement, body: &str) -> bool {
let short = &u.short;
let mut start = 0;
while let Some(pos) = body[start..].find(short.as_str()) {
let abs = start + pos;
let before_ok = abs == 0
|| !body
.as_bytes()
.get(abs - 1)
.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'\\');
let after_ok = body
.as_bytes()
.get(abs + short.len())
.is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_');
if before_ok && after_ok {
return true;
}
start = abs + 1;
}
false
}
fn line_start_byte(source: &str, line_no: u32) -> usize {
let mut current = 0u32;
let mut offset = 0;
for (i, c) in source.char_indices() {
if current == line_no {
return i;
}
if c == '\n' {
current += 1;
offset = i + 1;
}
}
if current == line_no {
offset
} else {
source.len()
}
}
fn utf16_col_to_byte(source: &str, line_start: usize, utf16_col: u32) -> usize {
let mut byte_off = line_start;
let mut col = 0u32;
for ch in source[line_start..].chars() {
if ch == '\n' || ch == '\r' || col >= utf16_col {
break;
}
col += ch.len_utf16() as u32;
byte_off += ch.len_utf8();
}
byte_off
}
fn byte_range_of(source: &str, range: Range) -> std::ops::Range<usize> {
let start = utf16_col_to_byte(
source,
line_start_byte(source, range.start.line),
range.start.character,
);
let end = utf16_col_to_byte(
source,
line_start_byte(source, range.end.line),
range.end.character,
);
start..end.min(source.len())
}
#[cfg(test)]
mod tests {
use super::*;
fn uri() -> Url {
Url::parse("file:///test.php").unwrap()
}
#[test]
fn no_use_statements_returns_none() {
let src = "<?php\n\nclass Foo {}\n";
assert!(organize_imports_action(src, &uri()).is_none());
}
#[test]
fn already_sorted_single_import_returns_none() {
let src = "<?php\nuse App\\Mailer;\n\n$m = new Mailer();\n";
assert!(organize_imports_action(src, &uri()).is_none());
}
#[test]
fn unsorted_imports_are_sorted() {
let src =
"<?php\nuse App\\Zebra;\nuse App\\Alpha;\n\n$a = new Alpha();\n$z = new Zebra();\n";
let action = organize_imports_action(src, &uri());
assert!(action.is_some(), "should produce an action");
let CodeActionOrCommand::CodeAction(ca) = action.unwrap() else {
panic!("expected CodeAction");
};
let edits = ca
.edit
.unwrap()
.changes
.unwrap()
.into_values()
.next()
.unwrap();
let new_text = &edits[0].new_text;
let alpha_pos = new_text.find("Alpha").unwrap();
let zebra_pos = new_text.find("Zebra").unwrap();
assert!(alpha_pos < zebra_pos, "Alpha should come before Zebra");
}
#[test]
fn unused_import_is_removed() {
let src = "<?php\nuse App\\Mailer;\nuse App\\Logger;\n\n$m = new Mailer();\n";
let action = organize_imports_action(src, &uri());
assert!(
action.is_some(),
"should produce an action to remove Logger"
);
let CodeActionOrCommand::CodeAction(ca) = action.unwrap() else {
panic!("expected CodeAction");
};
let edits = ca
.edit
.unwrap()
.changes
.unwrap()
.into_values()
.next()
.unwrap();
let new_text = &edits[0].new_text;
assert!(!new_text.contains("Logger"), "Logger should be removed");
assert!(new_text.contains("Mailer"), "Mailer should be kept");
}
#[test]
fn aliased_import_uses_alias_for_usage_check() {
let src = "<?php\nuse App\\Mailer as Mail;\n\n$m = new Mail();\n";
assert!(
organize_imports_action(src, &uri()).is_none(),
"used aliased import should not be removed"
);
}
#[test]
fn aliased_import_kept_with_alias_syntax() {
let src =
"<?php\nuse App\\Zebra as Z;\nuse App\\Alpha;\n\n$a = new Alpha();\n$z = new Z();\n";
let action = organize_imports_action(src, &uri());
assert!(action.is_some());
let CodeActionOrCommand::CodeAction(ca) = action.unwrap() else {
panic!("expected CodeAction");
};
let edits = ca
.edit
.unwrap()
.changes
.unwrap()
.into_values()
.next()
.unwrap();
let new_text = &edits[0].new_text;
assert!(
new_text.contains("as Z"),
"aliased import should keep alias syntax"
);
}
#[test]
fn action_kind_is_source_organize_imports() {
let src =
"<?php\nuse App\\Zebra;\nuse App\\Alpha;\n\n$a = new Alpha();\n$z = new Zebra();\n";
let CodeActionOrCommand::CodeAction(ca) = organize_imports_action(src, &uri()).unwrap()
else {
panic!("expected CodeAction");
};
assert_eq!(ca.kind, Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS));
}
}