use std::collections::HashMap;
use std::sync::Arc;
use tower_lsp::lsp_types::{Position, Range, TextEdit, Url, WorkspaceEdit};
use crate::ast::{ParsedDoc, offset_to_position};
use crate::references::find_references_with_use;
use crate::util::utf16_pos_to_byte;
use crate::walk::{collect_var_refs_in_scope, property_refs_in_stmts};
pub fn rename(word: &str, new_name: &str, all_docs: &[(Url, Arc<ParsedDoc>)]) -> WorkspaceEdit {
let locations = find_references_with_use(word, all_docs, true);
let mut changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
for loc in locations {
changes.entry(loc.uri).or_default().push(TextEdit {
range: loc.range,
new_text: new_name.to_string(),
});
}
WorkspaceEdit {
changes: Some(changes),
..Default::default()
}
}
pub fn prepare_rename(source: &str, position: Position) -> Option<Range> {
use crate::util::word_at;
let word = word_at(source, position)?;
if word.contains('\\') {
return None;
}
let line = source.lines().nth(position.line as usize)?;
let col = position.character as usize;
let chars: Vec<char> = line.chars().collect();
let is_word = |c: char| c.is_alphanumeric() || c == '_';
let mut utf16_col = 0usize;
let mut char_idx = 0usize;
for ch in &chars {
if utf16_col >= col {
break;
}
utf16_col += ch.len_utf16();
char_idx += 1;
}
let mut left = char_idx;
while left > 0 && is_word(chars[left - 1]) {
left -= 1;
}
let start_utf16: u32 = chars[..left].iter().map(|c| c.len_utf16() as u32).sum();
let end_utf16: u32 = start_utf16 + word.chars().map(|c| c.len_utf16() as u32).sum::<u32>();
Some(Range {
start: Position {
line: position.line,
character: start_utf16,
},
end: Position {
line: position.line,
character: end_utf16,
},
})
}
pub fn rename_variable(
var_name: &str,
new_name: &str,
uri: &Url,
source: &str,
doc: &ParsedDoc,
position: Position,
) -> WorkspaceEdit {
let bare = var_name.trim_start_matches('$');
let new_bare = new_name.trim_start_matches('$');
let new_text = format!("${new_bare}");
let byte_off = utf16_pos_to_byte(source, position);
let stmts = &doc.program().stmts;
let mut spans = Vec::new();
collect_var_refs_in_scope(stmts, bare, byte_off, &mut spans);
let mut edits: Vec<TextEdit> = spans
.into_iter()
.map(|span| {
let start = offset_to_position(source, span.start);
let end = offset_to_position(source, span.end);
TextEdit {
range: Range { start, end },
new_text: new_text.clone(),
}
})
.collect();
edits.sort_by_key(|e| (e.range.start.line, e.range.start.character));
edits.dedup_by_key(|e| (e.range.start.line, e.range.start.character));
let mut changes = HashMap::new();
if !edits.is_empty() {
changes.insert(uri.clone(), edits);
}
WorkspaceEdit {
changes: if changes.is_empty() {
None
} else {
Some(changes)
},
..Default::default()
}
}
pub fn rename_property(
prop_name: &str,
new_name: &str,
all_docs: &[(Url, Arc<ParsedDoc>)],
) -> WorkspaceEdit {
let mut changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
for (uri, doc) in all_docs {
let source = doc.source();
let mut spans = Vec::new();
property_refs_in_stmts(source, &doc.program().stmts, prop_name, &mut spans);
if !spans.is_empty() {
let mut edits: Vec<TextEdit> = spans
.into_iter()
.map(|span| {
let start = offset_to_position(source, span.start);
let end = offset_to_position(source, span.end);
TextEdit {
range: Range { start, end },
new_text: new_name.to_string(),
}
})
.collect();
edits.sort_by_key(|e| (e.range.start.line, e.range.start.character));
edits.dedup_by_key(|e| (e.range.start.line, e.range.start.character));
changes.insert(uri.clone(), edits);
}
}
WorkspaceEdit {
changes: if changes.is_empty() {
None
} else {
Some(changes)
},
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn uri(path: &str) -> Url {
Url::parse(&format!("file://{path}")).unwrap()
}
fn doc(path: &str, source: &str) -> (Url, Arc<ParsedDoc>) {
(uri(path), Arc::new(ParsedDoc::parse(source.to_string())))
}
fn pos(line: u32, character: u32) -> Position {
Position { line, character }
}
#[test]
fn rename_replaces_all_occurrences_in_single_file() {
let src = "<?php\nfunction greet() {}\ngreet();\ngreet();";
let docs = vec![doc("/a.php", src)];
let edit = rename("greet", "hello", &docs);
let changes = edit.changes.unwrap();
let edits = &changes[&uri("/a.php")];
assert!(
edits.len() >= 2,
"expected at least 2 edits, got {}",
edits.len()
);
assert!(edits.iter().all(|e| e.new_text == "hello"));
}
#[test]
fn rename_includes_declaration_site() {
let src = "<?php\nfunction greet() {}\ngreet();";
let docs = vec![doc("/a.php", src)];
let edit = rename("greet", "hello", &docs);
let changes = edit.changes.unwrap();
let edits = &changes[&uri("/a.php")];
assert!(edits.len() >= 2, "should include declaration");
}
#[test]
fn rename_across_files() {
let a = doc("/a.php", "<?php\nfunction helper() {}");
let b = doc("/b.php", "<?php\nhelper();");
let docs = vec![a, b];
let edit = rename("helper", "util", &docs);
let changes = edit.changes.unwrap();
assert!(
changes.contains_key(&uri("/a.php")),
"should rename declaration in a.php"
);
assert!(
changes.contains_key(&uri("/b.php")),
"should rename usage in b.php"
);
}
#[test]
fn prepare_rename_returns_word_range() {
let src = "<?php\nfunction greet() {}";
let result = prepare_rename(src, pos(1, 10));
assert!(result.is_some(), "expected range for 'greet'");
let range = result.unwrap();
assert_eq!(range.start.line, 1);
}
#[test]
fn prepare_rename_rejects_fqn() {
let src = "<?php\nFoo\\Bar::baz();";
let result = prepare_rename(src, pos(1, 5));
assert!(
result.is_none(),
"should not allow renaming FQNs with backslash"
);
}
#[test]
fn rename_does_not_match_partial_words() {
let src = "<?php\nfunction foo() {}\nfunction foobar() {}\nfunction barfoo() {}\nfoo();\nfoobar();\nbarfoo();";
let docs = vec![doc("/a.php", src)];
let edit = rename("foo", "baz", &docs);
let changes = edit.changes.unwrap();
let edits = &changes[&uri("/a.php")];
for e in edits {
assert_eq!(e.new_text, "baz", "all edits should replace with 'baz'");
let span_len = e.range.end.character - e.range.start.character;
assert_eq!(
span_len, 3,
"renamed span should be length 3 (the word 'foo'), got {} at {:?}",
span_len, e.range
);
}
let renamed_lines: Vec<u32> = edits.iter().map(|e| e.range.start.line).collect();
assert!(
!renamed_lines.contains(&5),
"foobar() call (line 5) should not be renamed"
);
assert!(
!renamed_lines.contains(&6),
"barfoo() call (line 6) should not be renamed"
);
}
#[test]
fn rename_updates_use_statement() {
let a = doc("/a.php", "<?php\nclass Foo {}");
let b = doc("/b.php", "<?php\nuse Foo;\n$x = new Foo();");
let docs = vec![a, b];
let edit = rename("Foo", "Bar", &docs);
let changes = edit.changes.unwrap();
assert!(
changes.contains_key(&uri("/a.php")),
"should rename class declaration in a.php"
);
let b_edits = &changes[&uri("/b.php")];
assert!(
b_edits.len() >= 2,
"expected at least 2 edits in b.php (use + new), got: {:?}",
b_edits
);
assert!(
b_edits.iter().all(|e| e.new_text == "Bar"),
"all edits in b.php should rename to 'Bar'"
);
let has_use_edit = b_edits.iter().any(|e| e.range.start.line == 1);
assert!(
has_use_edit,
"expected an edit on the use statement line (line 1) in b.php"
);
}
#[test]
fn rename_variable_within_function() {
let src = "<?php\nfunction foo() {\n $x = 1;\n echo $x;\n}";
let doc = Arc::new(ParsedDoc::parse(src.to_string()));
let edit = rename_variable("$x", "$y", &uri("/a.php"), src, &doc, pos(2, 5));
let changes = edit.changes.unwrap();
let edits = &changes[&uri("/a.php")];
assert!(edits.len() >= 2, "should rename both assignment and usage");
assert!(edits.iter().all(|e| e.new_text == "$y"));
}
#[test]
fn rename_variable_does_not_cross_function_boundary() {
let src = "<?php\nfunction foo() { $x = 1; }\nfunction bar() { $x = 2; }";
let doc = Arc::new(ParsedDoc::parse(src.to_string()));
let edit = rename_variable("$x", "$z", &uri("/a.php"), src, &doc, pos(1, 20));
let changes = edit.changes.unwrap();
let edits = &changes[&uri("/a.php")];
assert_eq!(edits.len(), 1, "should only rename within foo()");
}
#[test]
fn prepare_rename_allows_variables() {
let src = "<?php\n$foo = 1;";
let result = prepare_rename(src, pos(1, 1));
assert!(result.is_some(), "should allow renaming variables now");
}
#[test]
fn rename_property_renames_declaration_and_accesses() {
let src = "<?php\nclass Foo {\n public string $name;\n public function get() { return $this->name; }\n}";
let docs = vec![doc("/a.php", src)];
let edit = rename_property("name", "title", &docs);
let changes = edit.changes.unwrap();
let edits = &changes[&uri("/a.php")];
assert!(edits.len() >= 2, "should rename declaration and access");
assert!(edits.iter().all(|e| e.new_text == "title"));
}
#[test]
fn rename_property_works_across_files() {
let a = doc("/a.php", "<?php\nclass Foo {\n public int $count;\n}");
let b = doc("/b.php", "<?php\n$foo = new Foo();\necho $foo->count;");
let docs = vec![a, b];
let edit = rename_property("count", "total", &docs);
let changes = edit.changes.unwrap();
assert!(changes.contains_key(&uri("/a.php")), "declaration in a.php");
assert!(changes.contains_key(&uri("/b.php")), "access in b.php");
}
}