mod tests;
use std::collections::HashMap;
use std::sync::atomic::Ordering;
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::symbol_map::SymbolKind;
use crate::util::{build_fqn, offset_to_position, ranges_overlap, strip_fqn_prefix};
impl Backend {
pub(crate) fn handle_prepare_rename(
&self,
uri: &str,
content: &str,
position: Position,
) -> Option<PrepareRenameResponse> {
let span = self.lookup_symbol_at_position(uri, content, position)?;
if let SymbolKind::SelfStaticParent(_) = &span.kind {
return None;
}
let (name, range) =
self.renameable_symbol_info(uri, content, &span.kind, span.start, span.end)?;
if self.is_vendor_symbol(uri, content, position) {
return None;
}
Some(PrepareRenameResponse::RangeWithPlaceholder {
range,
placeholder: name,
})
}
pub(crate) fn handle_rename(
&self,
uri: &str,
content: &str,
position: Position,
new_name: &str,
) -> Option<WorkspaceEdit> {
let span = self.lookup_symbol_at_position(uri, content, position)?;
if let SymbolKind::SelfStaticParent(_) = &span.kind {
return None;
}
if self.is_vendor_symbol(uri, content, position) {
return None;
}
let class_rename_fqn = self.resolve_class_rename_fqn(&span.kind, uri, span.start);
let locations = self.find_references(uri, content, position, true)?;
if locations.is_empty() {
return None;
}
let is_property = self.is_property_rename(&span.kind, uri, &span);
let is_variable = matches!(&span.kind, SymbolKind::Variable { .. }) && !is_property;
if let Some(ref fqn) = class_rename_fqn {
return self.build_class_rename_edit(fqn, new_name, &locations);
}
let mut changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
for location in &locations {
let loc_uri_str = location.uri.to_string();
let loc_content = if loc_uri_str == uri {
Some(content.to_string())
} else {
self.get_file_content(&loc_uri_str)
};
let edit_text = if is_variable {
if new_name.starts_with('$') {
new_name.to_string()
} else {
format!("${}", new_name)
}
} else if is_property {
let has_dollar = loc_content.as_ref().is_some_and(|c| {
let start_off = crate::util::position_to_byte_offset(c, location.range.start);
c.as_bytes().get(start_off) == Some(&b'$')
});
let bare_name = new_name.strip_prefix('$').unwrap_or(new_name);
if has_dollar {
format!("${}", bare_name)
} else {
bare_name.to_string()
}
} else {
new_name.to_string()
};
let text_edit = TextEdit {
range: location.range,
new_text: edit_text,
};
changes
.entry(location.uri.clone())
.or_default()
.push(text_edit);
}
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
fn resolve_class_rename_fqn(
&self,
kind: &SymbolKind,
uri: &str,
offset: u32,
) -> Option<String> {
match kind {
SymbolKind::ClassReference { name, is_fqn } => {
let ctx = self.file_context(uri);
let fqn = if *is_fqn {
name.clone()
} else {
ctx.resolve_name_at(name, offset)
};
Some(strip_fqn_prefix(&fqn).to_string())
}
SymbolKind::ClassDeclaration { name } => {
let ctx = self.file_context(uri);
Some(build_fqn(name, &ctx.namespace))
}
_ => None,
}
}
fn should_rename_file(&self, old_fqn: &str, new_short_name: &str) -> Option<(Url, Url)> {
if !self.supports_file_rename.load(Ordering::Acquire) {
return None;
}
let old_short = crate::util::short_name(old_fqn);
let def_uri_str = self.class_index.read().get(old_fqn).cloned()?;
let def_url = Url::parse(&def_uri_str).ok()?;
let def_path = def_url.to_file_path().ok()?;
let stem = def_path.file_stem()?.to_str()?;
if stem != old_short {
return None;
}
let classes = self.get_classes_for_uri(&def_uri_str)?;
if classes.len() != 1 {
return None;
}
let mut new_path = def_path.clone();
new_path.set_file_name(format!("{}.php", new_short_name));
let new_url = Url::from_file_path(&new_path).ok()?;
Some((def_url, new_url))
}
fn convert_to_document_changes(
changes: HashMap<Url, Vec<TextEdit>>,
old_uri: &Url,
new_uri: &Url,
) -> DocumentChanges {
let mut ops: Vec<DocumentChangeOperation> = Vec::new();
ops.push(DocumentChangeOperation::Op(ResourceOp::Rename(
RenameFile {
old_uri: old_uri.clone(),
new_uri: new_uri.clone(),
options: None,
annotation_id: None,
},
)));
for (uri, edits) in changes {
let target_uri = if uri == *old_uri {
new_uri.clone()
} else {
uri
};
let text_doc_edit = TextDocumentEdit {
text_document: OptionalVersionedTextDocumentIdentifier {
uri: target_uri,
version: None,
},
edits: edits.into_iter().map(OneOf::Left).collect(),
};
ops.push(DocumentChangeOperation::Edit(text_doc_edit));
}
DocumentChanges::Operations(ops)
}
fn build_class_rename_edit(
&self,
old_fqn: &str,
new_short_name: &str,
locations: &[Location],
) -> Option<WorkspaceEdit> {
let old_fqn_normalized = strip_fqn_prefix(old_fqn);
let old_short_name = crate::util::short_name(old_fqn_normalized);
let new_fqn = if let Some(ns_sep) = old_fqn_normalized.rfind('\\') {
format!("{}\\{}", &old_fqn_normalized[..ns_sep], new_short_name)
} else {
new_short_name.to_string()
};
let mut locations_by_file: HashMap<String, Vec<&Location>> = HashMap::new();
for loc in locations {
locations_by_file
.entry(loc.uri.to_string())
.or_default()
.push(loc);
}
let mut changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
for (file_uri_str, file_locations) in &locations_by_file {
let file_content = self.get_file_content(file_uri_str);
let file_content = match file_content {
Some(c) => c,
None => continue,
};
let file_use_map = self
.use_map
.read()
.get(file_uri_str)
.cloned()
.unwrap_or_default();
let parsed_uri = match Url::parse(file_uri_str) {
Ok(u) => u,
Err(_) => continue,
};
let import_info = find_import_for_fqn(&file_use_map, old_fqn_normalized);
let has_collision = import_info.is_some()
&& new_short_name != old_short_name
&& has_import_collision(&file_use_map, old_fqn_normalized, new_short_name);
let (skip_alias_refs, in_code_replacement) = match &import_info {
Some(info) if info.alias != old_short_name => {
(true, info.alias.clone())
}
Some(_) if has_collision => {
let alias = pick_collision_alias(new_short_name, &file_use_map);
(false, alias)
}
_ => {
(false, new_short_name.to_string())
}
};
let use_line_range = if import_info.is_some() {
find_use_line_range(&file_content, old_fqn_normalized)
} else {
None
};
let mut file_edits: Vec<TextEdit> = Vec::new();
for loc in file_locations {
let start_off =
crate::util::position_to_byte_offset(&file_content, loc.range.start);
let end_off = crate::util::position_to_byte_offset(&file_content, loc.range.end);
let source_text = file_content
.get(start_off..end_off)
.unwrap_or("")
.to_string();
if let Some(ref ul) = use_line_range
&& ranges_overlap(&loc.range, &ul.range)
{
continue;
}
if source_text.contains('\\') {
let new_text = if let Some(ns_sep) = source_text.rfind('\\') {
format!("{}{}", &source_text[..=ns_sep], new_short_name)
} else {
new_short_name.to_string()
};
file_edits.push(TextEdit {
range: loc.range,
new_text,
});
} else if skip_alias_refs && source_text == import_info.as_ref().unwrap().alias {
continue;
} else {
file_edits.push(TextEdit {
range: loc.range,
new_text: in_code_replacement.clone(),
});
}
}
if let Some(ref info) = import_info
&& let Some(ref ul) = use_line_range
{
let new_line =
build_use_line(&new_fqn, info, has_collision, new_short_name, &file_use_map);
file_edits.push(TextEdit {
range: ul.range,
new_text: new_line,
});
}
if !file_edits.is_empty() {
changes.entry(parsed_uri).or_default().extend(file_edits);
}
}
if changes.is_empty() {
return None;
}
if let Some((old_file_uri, new_file_uri)) =
self.should_rename_file(old_fqn_normalized, new_short_name)
{
let doc_changes =
Self::convert_to_document_changes(changes, &old_file_uri, &new_file_uri);
return Some(WorkspaceEdit {
changes: None,
document_changes: Some(doc_changes),
change_annotations: None,
});
}
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
fn renameable_symbol_info(
&self,
_uri: &str,
content: &str,
kind: &SymbolKind,
start: u32,
end: u32,
) -> Option<(String, Range)> {
let range = Range {
start: offset_to_position(content, start as usize),
end: offset_to_position(content, end as usize),
};
match kind {
SymbolKind::Variable { name } => {
Some((format!("${}", name), range))
}
SymbolKind::ClassReference { name, .. } => Some((name.clone(), range)),
SymbolKind::ClassDeclaration { name } => Some((name.clone(), range)),
SymbolKind::MemberAccess { member_name, .. } => Some((member_name.clone(), range)),
SymbolKind::MemberDeclaration { name, .. } => Some((name.clone(), range)),
SymbolKind::FunctionCall { name, .. } => Some((name.clone(), range)),
SymbolKind::ConstantReference { name } => Some((name.clone(), range)),
SymbolKind::SelfStaticParent { .. } => None,
}
}
fn is_vendor_symbol(&self, uri: &str, content: &str, position: Position) -> bool {
let vendor_prefixes = self.vendor_uri_prefixes.lock().clone();
if vendor_prefixes.is_empty() {
return false;
}
if let Some(loc) = self.resolve_definition(uri, content, position) {
let def_uri = loc.uri.to_string();
if vendor_prefixes
.iter()
.any(|p| def_uri.starts_with(p.as_str()))
{
return true;
}
}
false
}
fn is_property_rename(
&self,
kind: &SymbolKind,
uri: &str,
span: &crate::symbol_map::SymbolSpan,
) -> bool {
match kind {
SymbolKind::MemberAccess { is_method_call, .. } => !is_method_call,
SymbolKind::MemberDeclaration { .. } => {
let is_method = self
.get_classes_for_uri(uri)
.iter()
.flat_map(|classes| classes.iter())
.flat_map(|c| c.methods.iter())
.any(|m| m.name_offset != 0 && m.name_offset == span.start);
let is_constant = self
.get_classes_for_uri(uri)
.iter()
.flat_map(|classes| classes.iter())
.flat_map(|c| c.constants.iter())
.any(|con| con.name_offset != 0 && con.name_offset == span.start);
!is_method && !is_constant
}
SymbolKind::Variable { name } => {
self.lookup_var_def_kind_at(uri, name, span.start)
.is_some_and(|k| k == crate::symbol_map::VarDefKind::Property)
}
_ => false,
}
}
}
struct UseLineRange {
range: Range,
}
struct ImportInfo {
alias: String,
has_explicit_alias: bool,
}
fn find_import_for_fqn(use_map: &HashMap<String, String>, target_fqn: &str) -> Option<ImportInfo> {
let target_normalized = strip_fqn_prefix(target_fqn);
let target_short = crate::util::short_name(target_normalized);
for (alias, fqn) in use_map {
let fqn_normalized = strip_fqn_prefix(fqn);
if fqn_normalized.eq_ignore_ascii_case(target_normalized) {
let has_explicit_alias = !alias.eq_ignore_ascii_case(target_short);
return Some(ImportInfo {
alias: alias.clone(),
has_explicit_alias,
});
}
}
None
}
fn has_import_collision(
use_map: &HashMap<String, String>,
old_fqn: &str,
new_short_name: &str,
) -> bool {
let old_normalized = strip_fqn_prefix(old_fqn);
let new_lower = new_short_name.to_lowercase();
for (alias, fqn) in use_map {
let fqn_normalized = strip_fqn_prefix(fqn);
if fqn_normalized.eq_ignore_ascii_case(old_normalized) {
continue;
}
if alias.to_lowercase() == new_lower {
return true;
}
}
false
}
fn pick_collision_alias(base_name: &str, use_map: &HashMap<String, String>) -> String {
let candidate = format!("{}Alias", base_name);
if !use_map.contains_key(&candidate) {
return candidate;
}
for i in 2..100 {
let candidate = format!("{}Alias{}", base_name, i);
if !use_map.contains_key(&candidate) {
return candidate;
}
}
format!("{}Alias99", base_name)
}
fn find_use_line_range(content: &str, old_fqn: &str) -> Option<UseLineRange> {
let old_fqn_normalized = strip_fqn_prefix(old_fqn);
for (line_idx, line) in content.lines().enumerate() {
let trimmed = line.trim();
if !trimmed.starts_with("use ") {
continue;
}
let rest = trimmed.strip_prefix("use ")?.trim();
let rest = rest.strip_suffix(';').unwrap_or(rest).trim();
let (fqn_part, _) = if let Some(as_pos) = rest.find(" as ") {
(rest[..as_pos].trim(), Some(&rest[as_pos + 4..]))
} else {
(rest, None)
};
if !fqn_part.eq_ignore_ascii_case(old_fqn_normalized) {
continue;
}
let line_start_byte: usize = content.lines().take(line_idx).map(|l| l.len() + 1).sum();
let line_end_byte = line_start_byte + line.len();
let start_pos = offset_to_position(content, line_start_byte);
let end_pos = offset_to_position(content, line_end_byte);
return Some(UseLineRange {
range: Range {
start: start_pos,
end: end_pos,
},
});
}
None
}
fn build_use_line(
new_fqn: &str,
import_info: &ImportInfo,
has_collision: bool,
new_short_name: &str,
use_map: &HashMap<String, String>,
) -> String {
if has_collision {
let alias = pick_collision_alias(new_short_name, use_map);
format!("use {} as {};", new_fqn, alias)
} else if import_info.has_explicit_alias {
format!("use {} as {};", new_fqn, import_info.alias)
} else {
format!("use {};", new_fqn)
}
}