#![cfg(test)]
use crate::Backend;
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;
async fn open_file(backend: &Backend, uri: &Url, text: &str) {
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;
}
async fn prepare_rename(
backend: &Backend,
uri: &Url,
line: u32,
character: u32,
) -> Option<PrepareRenameResponse> {
let params = TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position { line, character },
};
backend.prepare_rename(params).await.unwrap()
}
async fn rename(
backend: &Backend,
uri: &Url,
line: u32,
character: u32,
new_name: &str,
) -> Option<WorkspaceEdit> {
let params = RenameParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position { line, character },
},
new_name: new_name.to_string(),
work_done_progress_params: WorkDoneProgressParams::default(),
};
backend.rename(params).await.unwrap()
}
fn edits_for_uri(edit: &WorkspaceEdit, uri: &Url) -> Vec<TextEdit> {
edit.changes
.as_ref()
.and_then(|changes| changes.get(uri))
.cloned()
.unwrap_or_default()
}
fn apply_edits(source: &str, edits: &[TextEdit]) -> String {
let mut sorted: Vec<_> = edits.to_vec();
sorted.sort_by(|a, b| {
b.range
.start
.line
.cmp(&a.range.start.line)
.then(b.range.start.character.cmp(&a.range.start.character))
});
let lines: Vec<&str> = source.lines().collect();
let mut result = source.to_string();
for edit in &sorted {
let start_offset = line_col_to_offset(&lines, edit.range.start);
let end_offset = line_col_to_offset(&lines, edit.range.end);
result.replace_range(start_offset..end_offset, &edit.new_text);
}
result
}
fn line_col_to_offset(lines: &[&str], pos: Position) -> usize {
let mut offset = 0;
for (i, line) in lines.iter().enumerate() {
if i == pos.line as usize {
return offset + pos.character as usize;
}
offset += line.len() + 1; }
offset
}
#[tokio::test]
async fn rename_variable_in_function() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"function demo(): void {\n",
" $user = new User();\n",
" $user->name = 'Alice';\n",
" echo $user->name;\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 2, 5, "$person").await;
assert!(
edit.is_some(),
"Expected a workspace edit for variable rename"
);
let edit = edit.unwrap();
let file_edits = edits_for_uri(&edit, &uri);
assert!(
file_edits.len() >= 3,
"Expected at least 3 edits for $user (decl + 2 usages), got {}",
file_edits.len()
);
for te in &file_edits {
assert_eq!(te.new_text, "$person");
}
}
#[tokio::test]
async fn rename_variable_without_dollar_prefix() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"function demo(): void {\n",
" $x = 1;\n",
" echo $x;\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 2, 5, "y").await;
assert!(edit.is_some());
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
for te in &file_edits {
assert_eq!(te.new_text, "$y");
}
}
#[tokio::test]
async fn prepare_rename_variable() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"function demo(): void {\n",
" $count = 0;\n",
" $count++;\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let response = prepare_rename(&backend, &uri, 2, 6).await;
assert!(
response.is_some(),
"Expected prepare rename to succeed for $count"
);
if let Some(PrepareRenameResponse::RangeWithPlaceholder { placeholder, .. }) = response {
assert_eq!(placeholder, "$count");
} else {
panic!("Expected RangeWithPlaceholder response");
}
}
#[tokio::test]
async fn prepare_rename_rejects_this() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" public function bar(): void {\n",
" $this->baz();\n",
" }\n",
" public function baz(): void {}\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let response = prepare_rename(&backend, &uri, 3, 9).await;
assert!(response.is_none(), "$this should not be renameable");
}
#[tokio::test]
async fn prepare_rename_rejects_self() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" public static function create(): self {\n",
" return new self();\n",
" }\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let response = prepare_rename(&backend, &uri, 3, 20).await;
assert!(response.is_none(), "self keyword should not be renameable");
}
#[tokio::test]
async fn prepare_rename_rejects_static() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {\n",
" public static function create(): static {\n",
" return new static();\n",
" }\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let response = prepare_rename(&backend, &uri, 3, 22).await;
assert!(
response.is_none(),
"static keyword should not be renameable"
);
}
#[tokio::test]
async fn prepare_rename_rejects_parent() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Base {\n",
" public function hello(): void {}\n",
"}\n",
"class Child extends Base {\n",
" public function hello(): void {\n",
" parent::hello();\n",
" }\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let response = prepare_rename(&backend, &uri, 6, 10).await;
assert!(
response.is_none(),
"parent keyword should not be renameable"
);
}
#[tokio::test]
async fn rename_class_same_file() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Logger {\n",
" public function log(string $msg): void {}\n",
"}\n",
"function demo(Logger $logger): void {\n",
" $obj = new Logger();\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 4, 16, "AppLogger").await;
assert!(edit.is_some(), "Expected a workspace edit for class rename");
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
assert!(
file_edits.len() >= 3,
"Expected at least 3 edits for Logger, got {}",
file_edits.len()
);
for te in &file_edits {
assert_eq!(te.new_text, "AppLogger");
}
}
#[tokio::test]
async fn rename_class_from_declaration() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Widget {\n",
" public function render(): string { return ''; }\n",
"}\n",
"function demo(Widget $w): void {\n",
" $obj = new Widget();\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 1, 7, "Component").await;
assert!(edit.is_some());
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
assert!(
file_edits.len() >= 3,
"Expected at least 3 edits for Widget, got {}",
file_edits.len()
);
for te in &file_edits {
assert_eq!(te.new_text, "Component");
}
}
#[tokio::test]
async fn prepare_rename_class() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Foo {}\n",
"function demo(Foo $f): void {}\n",
);
open_file(&backend, &uri, text).await;
let response = prepare_rename(&backend, &uri, 1, 7).await;
assert!(response.is_some());
if let Some(PrepareRenameResponse::RangeWithPlaceholder { placeholder, .. }) = response {
assert_eq!(placeholder, "Foo");
} else {
panic!("Expected RangeWithPlaceholder response");
}
}
#[tokio::test]
async fn rename_method() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Service {\n",
" public function process(): void {}\n",
"}\n",
"function demo(): void {\n",
" $s = new Service();\n",
" $s->process();\n",
" $s->process();\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 6, 9, "execute").await;
assert!(
edit.is_some(),
"Expected a workspace edit for method rename"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
assert!(
file_edits.len() >= 3,
"Expected at least 3 edits for process, got {}",
file_edits.len()
);
for te in &file_edits {
assert_eq!(te.new_text, "execute");
}
}
#[tokio::test]
async fn rename_static_method() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Factory {\n",
" public static function create(): self { return new self(); }\n",
"}\n",
"function demo(): void {\n",
" Factory::create();\n",
" Factory::create();\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 5, 14, "build").await;
assert!(edit.is_some());
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
assert!(
file_edits.len() >= 3,
"Expected at least 3 edits for create, got {}",
file_edits.len()
);
for te in &file_edits {
assert_eq!(te.new_text, "build");
}
}
#[tokio::test]
async fn rename_property_from_access() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public string $name = '';\n",
" public function greet(): string {\n",
" return $this->name;\n",
" }\n",
"}\n",
"function demo(): void {\n",
" $u = new User();\n",
" $u->name = 'Alice';\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 9, 9, "displayName").await;
assert!(
edit.is_some(),
"Expected a workspace edit for property rename"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
assert!(
file_edits.len() >= 3,
"Expected at least 3 edits for name property, got {}",
file_edits.len()
);
for te in &file_edits {
assert!(
te.new_text == "displayName" || te.new_text == "$displayName",
"Unexpected edit text: {}",
te.new_text
);
}
}
#[tokio::test]
async fn rename_function() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"function helper(): void {}\n",
"function demo(): void {\n",
" helper();\n",
" helper();\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 3, 6, "utility").await;
assert!(
edit.is_some(),
"Expected a workspace edit for function rename"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
assert!(
file_edits.len() >= 3,
"Expected at least 3 edits for helper, got {}",
file_edits.len()
);
for te in &file_edits {
assert_eq!(te.new_text, "utility");
}
}
#[tokio::test]
async fn rename_class_constant() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Status {\n",
" const ACTIVE = 1;\n",
"}\n",
"function demo(): void {\n",
" echo Status::ACTIVE;\n",
" $x = Status::ACTIVE;\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 5, 19, "ENABLED").await;
assert!(
edit.is_some(),
"Expected a workspace edit for constant rename"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
assert!(
file_edits.len() >= 3,
"Expected at least 3 edits for ACTIVE, got {}",
file_edits.len()
);
for te in &file_edits {
assert_eq!(te.new_text, "ENABLED");
}
}
#[tokio::test]
async fn rename_class_cross_file() {
let backend = Backend::new_test();
let uri_a = Url::parse("file:///a.php").unwrap();
let uri_b = Url::parse("file:///b.php").unwrap();
let text_a = concat!(
"<?php\n",
"class Animal {\n",
" public function speak(): string { return ''; }\n",
"}\n",
);
let text_b = concat!(
"<?php\n",
"function demo(Animal $a): void {\n",
" $obj = new Animal();\n",
"}\n",
);
open_file(&backend, &uri_a, text_a).await;
open_file(&backend, &uri_b, text_b).await;
let edit = rename(&backend, &uri_a, 1, 7, "Creature").await;
assert!(
edit.is_some(),
"Expected a workspace edit for cross-file class rename"
);
let edit = edit.unwrap();
let edits_a = edits_for_uri(&edit, &uri_a);
let edits_b = edits_for_uri(&edit, &uri_b);
assert!(
!edits_a.is_empty(),
"Expected edits in file a (declaration)"
);
assert!(!edits_b.is_empty(), "Expected edits in file b (references)");
for te in edits_a.iter().chain(edits_b.iter()) {
assert_eq!(te.new_text, "Creature");
}
}
#[tokio::test]
async fn rename_method_cross_file() {
let backend = Backend::new_test();
let uri_a = Url::parse("file:///a.php").unwrap();
let uri_b = Url::parse("file:///b.php").unwrap();
let text_a = concat!(
"<?php\n",
"class Printer {\n",
" public function print(): void {}\n",
"}\n",
);
let text_b = concat!(
"<?php\n",
"function demo(): void {\n",
" $p = new Printer();\n",
" $p->print();\n",
"}\n",
);
open_file(&backend, &uri_a, text_a).await;
open_file(&backend, &uri_b, text_b).await;
let edit = rename(&backend, &uri_b, 3, 9, "output").await;
assert!(edit.is_some());
let edit = edit.unwrap();
let edits_a = edits_for_uri(&edit, &uri_a);
let edits_b = edits_for_uri(&edit, &uri_b);
assert!(
!edits_a.is_empty(),
"Expected edits in file a (declaration)"
);
assert!(!edits_b.is_empty(), "Expected edits in file b (call site)");
for te in edits_a.iter().chain(edits_b.iter()) {
assert_eq!(te.new_text, "output");
}
}
#[tokio::test]
async fn prepare_rename_on_whitespace_returns_none() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!("<?php\n", "\n", "function demo(): void {}\n",);
open_file(&backend, &uri, text).await;
let response = prepare_rename(&backend, &uri, 1, 0).await;
assert!(response.is_none(), "Expected no rename on whitespace");
}
#[tokio::test]
async fn rename_on_whitespace_returns_none() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!("<?php\n", "\n", "function demo(): void {}\n",);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 1, 0, "anything").await;
assert!(edit.is_none(), "Expected no edit on whitespace");
}
#[tokio::test]
async fn rename_variable_produces_valid_php() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"function demo(): void {\n",
" $a = 1;\n",
" $b = $a + 2;\n",
" echo $a;\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 2, 5, "$z").await;
assert!(edit.is_some());
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(result.contains("$z = 1;"), "Declaration not renamed");
assert!(result.contains("$b = $z + 2;"), "RHS usage not renamed");
assert!(result.contains("echo $z;"), "Echo usage not renamed");
assert!(!result.contains("$a"), "Old variable name still present");
}
#[tokio::test]
async fn rename_variable_does_not_leak_across_functions() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"function alpha(): void {\n",
" $x = 1;\n",
" echo $x;\n",
"}\n",
"function beta(): void {\n",
" $x = 2;\n",
" echo $x;\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 2, 5, "$y").await;
assert!(edit.is_some());
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(result.contains("function alpha(): void {\n $y = 1;\n echo $y;\n}"));
assert!(result.contains("function beta(): void {\n $x = 2;\n echo $x;\n}"));
}
#[tokio::test]
async fn rename_method_does_not_leak_to_unrelated_class() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n", "class Dog {\n", " public function speak(): void {}\n", "}\n", "class Cat {\n", " public function speak(): void {}\n", "}\n", "function demo(Dog $d, Cat $c): void {\n", " $d->speak();\n", " $c->speak();\n", "}\n", );
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 2, 21, "bark").await;
assert!(edit.is_some(), "Rename should produce edits");
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("function bark()"),
"Dog's method should be renamed to bark; got:\n{}",
result
);
assert!(
result.contains("$d->bark()"),
"$d->speak() should become $d->bark(); got:\n{}",
result
);
assert!(
result.contains("class Cat {\n public function speak(): void {}"),
"Cat's method should remain speak; got:\n{}",
result
);
assert!(
result.contains("$c->speak()"),
"$c->speak() should remain unchanged; got:\n{}",
result
);
}
#[tokio::test]
async fn rename_method_includes_inherited_class() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n", "class Base {\n", " public function run(): void {}\n", "}\n", "class Child extends Base {}\n", "function demo(Base $b, Child $c): void {\n", " $b->run();\n", " $c->run();\n", "}\n", );
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 6, 10, "execute").await;
assert!(edit.is_some(), "Rename should produce edits");
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("$b->execute()"),
"$b->run() should become $b->execute(); got:\n{}",
result
);
assert!(
result.contains("$c->execute()"),
"$c->run() should become $c->execute() (inherited); got:\n{}",
result
);
}
#[tokio::test]
async fn rename_static_method_does_not_leak_to_unrelated_class() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n", "class Alpha {\n", " public static function create(): void {}\n", "}\n", "class Beta {\n", " public static function create(): void {}\n", "}\n", "function demo(): void {\n", " Alpha::create();\n", " Beta::create();\n", "}\n", );
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 8, 12, "make").await;
assert!(edit.is_some(), "Rename should produce edits");
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("Alpha::make()"),
"Alpha::create() should become Alpha::make(); got:\n{}",
result
);
assert!(
result.contains("Beta::create()"),
"Beta::create() should remain unchanged; got:\n{}",
result
);
}
#[tokio::test]
async fn rename_class_updates_use_import() {
let backend = Backend::new_test();
let uri_decl = Url::parse("file:///src/TaskResource.php").unwrap();
let uri_usage = Url::parse("file:///src/Task.php").unwrap();
let text_decl = concat!(
"<?php\n",
"namespace Eagle\\Tasks\\Resources;\n",
"\n",
"class TaskResource {}\n",
);
let text_usage = concat!(
"<?php\n",
"namespace Eagle\\Tasks;\n",
"\n",
"use Eagle\\Tasks\\Resources\\TaskResource;\n",
"\n",
"class Task {\n",
" protected static string $service = TaskResource::class;\n",
"}\n",
);
open_file(&backend, &uri_decl, text_decl).await;
open_file(&backend, &uri_usage, text_usage).await;
let edit = rename(&backend, &uri_decl, 3, 6, "TaskResourceService").await;
assert!(edit.is_some(), "Expected a workspace edit for class rename");
let ws = edit.unwrap();
let edits_usage = edits_for_uri(&ws, &uri_usage);
assert!(!edits_usage.is_empty(), "Expected edits in the usage file");
let result = apply_edits(text_usage, &edits_usage);
assert!(
result.contains("use Eagle\\Tasks\\Resources\\TaskResourceService;"),
"Use statement should be updated; got:\n{}",
result
);
assert!(
result.contains("TaskResourceService::class"),
"In-code reference should be renamed; got:\n{}",
result
);
assert!(
!result.contains("TaskResource::class"),
"Old name should not remain; got:\n{}",
result
);
}
#[tokio::test]
async fn rename_class_preserves_explicit_alias() {
let backend = Backend::new_test();
let uri_decl = Url::parse("file:///src/TaskResource.php").unwrap();
let uri_usage = Url::parse("file:///src/Controller.php").unwrap();
let text_decl = concat!(
"<?php\n",
"namespace Eagle\\Tasks\\Resources;\n",
"\n",
"class TaskResource {}\n",
);
let text_usage = concat!(
"<?php\n",
"namespace Eagle\\Tasks\\Http;\n",
"\n",
"use Eagle\\Tasks\\Resources\\TaskResource as ResourceService;\n",
"\n",
"class Controller {\n",
" private ResourceService $service;\n",
"}\n",
);
open_file(&backend, &uri_decl, text_decl).await;
open_file(&backend, &uri_usage, text_usage).await;
let edit = rename(&backend, &uri_decl, 3, 6, "TaskResourceService").await;
assert!(
edit.is_some(),
"Expected a workspace edit for aliased class rename"
);
let ws = edit.unwrap();
let edits_usage = edits_for_uri(&ws, &uri_usage);
let result = apply_edits(text_usage, &edits_usage);
assert!(
result.contains("use Eagle\\Tasks\\Resources\\TaskResourceService as ResourceService;"),
"Use statement FQN should update, alias preserved; got:\n{}",
result
);
assert!(
result.contains("private ResourceService $service;"),
"Alias-based references should remain unchanged; got:\n{}",
result
);
}
#[tokio::test]
async fn rename_class_with_collision_adds_alias() {
let backend = Backend::new_test();
let uri_a = Url::parse("file:///src/OldName.php").unwrap();
let uri_b = Url::parse("file:///src/NewName.php").unwrap();
let uri_usage = Url::parse("file:///src/Usage.php").unwrap();
let text_a = concat!("<?php\n", "namespace Ns\\A;\n", "\n", "class OldName {}\n",);
let text_b = concat!("<?php\n", "namespace Ns\\B;\n", "\n", "class NewName {}\n",);
let text_usage = concat!(
"<?php\n",
"use Ns\\A\\OldName;\n",
"use Ns\\B\\NewName;\n",
"\n",
"function demo(OldName $a, NewName $b): void {}\n",
);
open_file(&backend, &uri_a, text_a).await;
open_file(&backend, &uri_b, text_b).await;
open_file(&backend, &uri_usage, text_usage).await;
let edit = rename(&backend, &uri_a, 3, 6, "NewName").await;
assert!(
edit.is_some(),
"Expected a workspace edit for colliding class rename"
);
let ws = edit.unwrap();
let edits_usage = edits_for_uri(&ws, &uri_usage);
let result = apply_edits(text_usage, &edits_usage);
assert!(
result.contains("use Ns\\B\\NewName;"),
"Existing import should remain unchanged; got:\n{}",
result
);
assert!(
result.contains("use Ns\\A\\NewName as NewNameAlias;"),
"Renamed import should get an alias; got:\n{}",
result
);
assert!(
result.contains("NewNameAlias $a"),
"In-code references should use the alias; got:\n{}",
result
);
assert!(
result.contains("NewName $b"),
"Other class references should remain; got:\n{}",
result
);
}
#[tokio::test]
async fn rename_class_same_file_no_use_statement() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Logger {\n",
" public function log(string $msg): void {}\n",
"}\n",
"function demo(Logger $logger): void {\n",
" $obj = new Logger();\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 1, 7, "AppLogger").await;
assert!(edit.is_some(), "Expected a workspace edit");
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("class AppLogger"),
"Declaration should be renamed; got:\n{}",
result
);
assert!(
result.contains("function demo(AppLogger"),
"Type hint should be renamed; got:\n{}",
result
);
assert!(
result.contains("new AppLogger()"),
"new expression should be renamed; got:\n{}",
result
);
let has_standalone_old_name = result
.lines()
.any(|l| l.contains("Logger") && !l.contains("AppLogger"));
assert!(
!has_standalone_old_name,
"Old standalone name should not remain; got:\n{}",
result
);
}
#[tokio::test]
async fn rename_class_updates_use_import_from_reference_site() {
let backend = Backend::new_test();
let uri_decl = Url::parse("file:///src/Animal.php").unwrap();
let uri_usage = Url::parse("file:///src/Zoo.php").unwrap();
let text_decl = concat!(
"<?php\n",
"namespace Zoo\\Models;\n",
"\n",
"class Animal {}\n",
);
let text_usage = concat!(
"<?php\n",
"use Zoo\\Models\\Animal;\n",
"\n",
"function feed(Animal $a): void {}\n",
);
open_file(&backend, &uri_decl, text_decl).await;
open_file(&backend, &uri_usage, text_usage).await;
let edit = rename(&backend, &uri_usage, 3, 15, "Creature").await;
assert!(
edit.is_some(),
"Expected a workspace edit when renaming from reference"
);
let ws = edit.unwrap();
let edits_usage = edits_for_uri(&ws, &uri_usage);
let result = apply_edits(text_usage, &edits_usage);
assert!(
result.contains("use Zoo\\Models\\Creature;"),
"Use statement should be updated; got:\n{}",
result
);
assert!(
result.contains("function feed(Creature $a)"),
"In-code reference should be renamed; got:\n{}",
result
);
}
#[tokio::test]
async fn rename_class_cross_file_use_import_multiple_refs() {
let backend = Backend::new_test();
let uri_decl = Url::parse("file:///src/Repo.php").unwrap();
let uri_usage = Url::parse("file:///src/Service.php").unwrap();
let text_decl = concat!(
"<?php\n",
"namespace App\\Repos;\n",
"\n",
"class UserRepo {}\n",
);
let text_usage = concat!(
"<?php\n",
"use App\\Repos\\UserRepo;\n",
"\n",
"class Service {\n",
" private UserRepo $repo;\n",
" public function getRepo(): UserRepo {\n",
" return new UserRepo();\n",
" }\n",
"}\n",
);
open_file(&backend, &uri_decl, text_decl).await;
open_file(&backend, &uri_usage, text_usage).await;
let edit = rename(&backend, &uri_decl, 3, 6, "UserRepository").await;
assert!(edit.is_some());
let ws = edit.unwrap();
let edits_usage = edits_for_uri(&ws, &uri_usage);
let result = apply_edits(text_usage, &edits_usage);
assert!(
result.contains("use App\\Repos\\UserRepository;"),
"Use statement should be updated; got:\n{}",
result
);
assert!(
result.contains("private UserRepository $repo;"),
"Property type should be renamed; got:\n{}",
result
);
assert!(
result.contains("getRepo(): UserRepository"),
"Return type should be renamed; got:\n{}",
result
);
assert!(
result.contains("new UserRepository()"),
"new expression should be renamed; got:\n{}",
result
);
let has_standalone_old_name = result
.lines()
.any(|l| l.contains("UserRepo") && !l.contains("UserRepository"));
assert!(
!has_standalone_old_name,
"Old standalone name should not remain; got:\n{}",
result
);
}
#[tokio::test]
async fn rename_class_fqn_inline_reference() {
let backend = Backend::new_test();
let uri_decl = Url::parse("file:///src/Item.php").unwrap();
let uri_usage = Url::parse("file:///src/other.php").unwrap();
let text_decl = concat!("<?php\n", "namespace Shop;\n", "\n", "class Item {}\n",);
let text_usage = concat!(
"<?php\n",
"function demo(): void {\n",
" $x = new \\Shop\\Item();\n",
"}\n",
);
open_file(&backend, &uri_decl, text_decl).await;
open_file(&backend, &uri_usage, text_usage).await;
let edit = rename(&backend, &uri_decl, 3, 6, "Product").await;
assert!(edit.is_some());
let ws = edit.unwrap();
let edits_usage = edits_for_uri(&ws, &uri_usage);
let result = apply_edits(text_usage, &edits_usage);
assert!(
result.contains("\\Shop\\Product()"),
"Inline FQN should update last segment only; got:\n{}",
result
);
}
#[tokio::test]
async fn rename_class_declaration_updates_in_same_namespace() {
let backend = Backend::new_test();
let uri_a = Url::parse("file:///src/Foo.php").unwrap();
let uri_b = Url::parse("file:///src/Bar.php").unwrap();
let text_a = concat!("<?php\n", "namespace App;\n", "\n", "class Foo {}\n",);
let text_b = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"class Bar extends Foo {}\n",
);
open_file(&backend, &uri_a, text_a).await;
open_file(&backend, &uri_b, text_b).await;
let edit = rename(&backend, &uri_a, 3, 6, "Baz").await;
assert!(edit.is_some());
let ws = edit.unwrap();
let edits_a = edits_for_uri(&ws, &uri_a);
let edits_b = edits_for_uri(&ws, &uri_b);
let result_a = apply_edits(text_a, &edits_a);
let result_b = apply_edits(text_b, &edits_b);
assert!(
result_a.contains("class Baz"),
"Declaration should be renamed; got:\n{}",
result_a
);
assert!(
result_b.contains("extends Baz"),
"Cross-file reference should be renamed; got:\n{}",
result_b
);
}
fn extract_rename_file(edit: &WorkspaceEdit) -> Option<&RenameFile> {
let doc_changes = edit.document_changes.as_ref()?;
match doc_changes {
DocumentChanges::Operations(ops) => {
for op in ops {
if let DocumentChangeOperation::Op(ResourceOp::Rename(rf)) = op {
return Some(rf);
}
}
None
}
_ => None,
}
}
fn doc_change_edits_for_uri(edit: &WorkspaceEdit, uri: &Url) -> Vec<TextEdit> {
let Some(DocumentChanges::Operations(ops)) = &edit.document_changes else {
return Vec::new();
};
let mut result = Vec::new();
for op in ops {
if let DocumentChangeOperation::Edit(tde) = op
&& tde.text_document.uri == *uri
{
for e in &tde.edits {
match e {
OneOf::Left(te) => result.push(te.clone()),
OneOf::Right(ate) => result.push(TextEdit {
range: ate.text_edit.range,
new_text: ate.text_edit.new_text.clone(),
}),
}
}
}
}
result
}
#[tokio::test]
async fn rename_class_renames_file_when_psr4_match() {
let backend = Backend::new_test();
backend
.supports_file_rename
.store(true, std::sync::atomic::Ordering::Release);
let uri = Url::parse("file:///src/Foo.php").unwrap();
let text = concat!("<?php\n", "namespace App;\n", "\n", "class Foo {}\n",);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 3, 6, "Bar").await;
assert!(edit.is_some(), "Expected a workspace edit");
let ws = edit.unwrap();
assert!(
ws.document_changes.is_some(),
"Expected document_changes when file rename is included"
);
assert!(
ws.changes.is_none(),
"changes should be None when document_changes is used"
);
let rf = extract_rename_file(&ws);
assert!(rf.is_some(), "Expected a RenameFile operation");
let rf = rf.unwrap();
assert_eq!(
rf.old_uri.to_string(),
"file:///src/Foo.php",
"Old URI should be the original file"
);
assert_eq!(
rf.new_uri.to_string(),
"file:///src/Bar.php",
"New URI should use the new class name"
);
let new_uri = Url::parse("file:///src/Bar.php").unwrap();
let edits = doc_change_edits_for_uri(&ws, &new_uri);
assert!(
!edits.is_empty(),
"Expected text edits targeting the new file URI"
);
let has_bar = edits.iter().any(|e| e.new_text == "Bar");
assert!(has_bar, "Expected an edit renaming to Bar");
}
#[tokio::test]
async fn rename_class_no_file_rename_when_filename_mismatch() {
let backend = Backend::new_test();
backend
.supports_file_rename
.store(true, std::sync::atomic::Ordering::Release);
let uri = Url::parse("file:///src/helpers.php").unwrap();
let text = concat!("<?php\n", "namespace App;\n", "\n", "class Foo {}\n",);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 3, 6, "Bar").await;
assert!(edit.is_some());
let ws = edit.unwrap();
assert!(
ws.changes.is_some(),
"Expected plain changes when filename doesn't match class name"
);
assert!(
ws.document_changes.is_none(),
"Should not include document_changes"
);
}
#[tokio::test]
async fn rename_class_no_file_rename_when_multiple_classes() {
let backend = Backend::new_test();
backend
.supports_file_rename
.store(true, std::sync::atomic::Ordering::Release);
let uri = Url::parse("file:///src/Foo.php").unwrap();
let text = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"class Foo {}\n",
"class Extra {}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 3, 6, "Bar").await;
assert!(edit.is_some());
let ws = edit.unwrap();
assert!(
ws.changes.is_some(),
"Expected plain changes when multiple classes in file"
);
assert!(
ws.document_changes.is_none(),
"Should not include document_changes with multiple classes"
);
}
#[tokio::test]
async fn rename_class_no_file_rename_when_client_unsupported() {
let backend = Backend::new_test();
let uri = Url::parse("file:///src/Foo.php").unwrap();
let text = concat!("<?php\n", "namespace App;\n", "\n", "class Foo {}\n",);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 3, 6, "Bar").await;
assert!(edit.is_some());
let ws = edit.unwrap();
assert!(
ws.changes.is_some(),
"Expected plain changes when client does not support file rename"
);
assert!(
ws.document_changes.is_none(),
"Should not include document_changes without client support"
);
}
#[tokio::test]
async fn rename_class_cross_file_with_file_rename() {
let backend = Backend::new_test();
backend
.supports_file_rename
.store(true, std::sync::atomic::Ordering::Release);
let uri_decl = Url::parse("file:///src/TaskResource.php").unwrap();
let uri_usage = Url::parse("file:///src/Task.php").unwrap();
let text_decl = concat!(
"<?php\n",
"namespace Eagle\\Tasks\\Resources;\n",
"\n",
"class TaskResource {}\n",
);
let text_usage = concat!(
"<?php\n",
"namespace Eagle\\Tasks;\n",
"\n",
"use Eagle\\Tasks\\Resources\\TaskResource;\n",
"\n",
"class Task {\n",
" public function resource(): TaskResource {\n",
" return new TaskResource();\n",
" }\n",
"}\n",
);
open_file(&backend, &uri_decl, text_decl).await;
open_file(&backend, &uri_usage, text_usage).await;
let edit = rename(&backend, &uri_decl, 3, 6, "TaskDto").await;
assert!(edit.is_some(), "Expected workspace edit");
let ws = edit.unwrap();
assert!(ws.document_changes.is_some());
let rf = extract_rename_file(&ws);
assert!(rf.is_some(), "Expected a RenameFile operation");
let rf = rf.unwrap();
assert_eq!(rf.old_uri.to_string(), "file:///src/TaskResource.php");
assert_eq!(rf.new_uri.to_string(), "file:///src/TaskDto.php");
let usage_edits = doc_change_edits_for_uri(&ws, &uri_usage);
assert!(!usage_edits.is_empty(), "Expected edits in the usage file");
let result_usage = apply_edits(text_usage, &usage_edits);
assert!(
result_usage.contains("use Eagle\\Tasks\\Resources\\TaskDto;"),
"Use statement should be updated; got:\n{}",
result_usage
);
assert!(
result_usage.contains("TaskDto"),
"In-code references should be updated; got:\n{}",
result_usage
);
let new_decl_uri = Url::parse("file:///src/TaskDto.php").unwrap();
let decl_edits = doc_change_edits_for_uri(&ws, &new_decl_uri);
assert!(
!decl_edits.is_empty(),
"Expected edits targeting the new declaration file URI"
);
let result_decl = apply_edits(text_decl, &decl_edits);
assert!(
result_decl.contains("class TaskDto"),
"Class declaration should be renamed; got:\n{}",
result_decl
);
}
#[tokio::test]
async fn rename_class_from_reference_site_renames_file() {
let backend = Backend::new_test();
backend
.supports_file_rename
.store(true, std::sync::atomic::Ordering::Release);
let uri_decl = Url::parse("file:///src/Animal.php").unwrap();
let uri_usage = Url::parse("file:///src/Zoo.php").unwrap();
let text_decl = concat!(
"<?php\n",
"namespace Zoo\\Models;\n",
"\n",
"class Animal {}\n",
);
let text_usage = concat!(
"<?php\n",
"namespace Zoo;\n",
"\n",
"use Zoo\\Models\\Animal;\n",
"\n",
"class Zoo {\n",
" public function get(): Animal {\n",
" return new Animal();\n",
" }\n",
"}\n",
);
open_file(&backend, &uri_decl, text_decl).await;
open_file(&backend, &uri_usage, text_usage).await;
let edit = rename(&backend, &uri_usage, 6, 30, "Creature").await;
assert!(
edit.is_some(),
"Expected workspace edit from reference site"
);
let ws = edit.unwrap();
let rf = extract_rename_file(&ws);
assert!(rf.is_some(), "Expected a RenameFile operation");
let rf = rf.unwrap();
assert_eq!(rf.old_uri.to_string(), "file:///src/Animal.php");
assert_eq!(rf.new_uri.to_string(), "file:///src/Creature.php");
}
#[tokio::test]
async fn rename_class_no_file_rename_for_non_namespaced() {
let backend = Backend::new_test();
backend
.supports_file_rename
.store(true, std::sync::atomic::Ordering::Release);
let uri = Url::parse("file:///src/Widget.php").unwrap();
let text = concat!("<?php\n", "class Widget {}\n",);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 1, 6, "Gadget").await;
assert!(edit.is_some());
let ws = edit.unwrap();
let rf = extract_rename_file(&ws);
assert!(
rf.is_some(),
"Expected a RenameFile for non-namespaced class with matching filename"
);
let rf = rf.unwrap();
assert_eq!(rf.new_uri.to_string(), "file:///src/Gadget.php");
}
#[tokio::test]
async fn prepare_rename_enum_case_at_declaration() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n", "enum TaskType: int {\n", " case Task = 1;\n", " case Issue = 2;\n", " public function isIssue(): bool {\n", " return $this === self::Issue;\n", " }\n", "}\n", );
open_file(&backend, &uri, text).await;
let result = prepare_rename(&backend, &uri, 3, 9).await;
assert!(
result.is_some(),
"prepare_rename should succeed on enum case declaration"
);
if let Some(PrepareRenameResponse::RangeWithPlaceholder { placeholder, .. }) = result {
assert_eq!(
placeholder, "Issue",
"Placeholder should be the enum case name"
);
} else {
panic!("Expected RangeWithPlaceholder, got {:?}", result);
}
}
#[tokio::test]
async fn prepare_rename_enum_case_at_reference() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n", "enum TaskType: int {\n", " case Task = 1;\n", " case Issue = 2;\n", " public function isIssue(): bool {\n", " return $this === self::Issue;\n", " }\n", "}\n", );
open_file(&backend, &uri, text).await;
let result = prepare_rename(&backend, &uri, 5, 36).await;
assert!(
result.is_some(),
"prepare_rename should succeed on enum case reference"
);
if let Some(PrepareRenameResponse::RangeWithPlaceholder { placeholder, .. }) = result {
assert_eq!(
placeholder, "Issue",
"Placeholder should be the enum case name"
);
} else {
panic!("Expected RangeWithPlaceholder, got {:?}", result);
}
}
#[tokio::test]
async fn rename_enum_case_from_declaration() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n", "enum TaskType: int {\n", " case Task = 1;\n", " case Issue = 2;\n", " public function isIssue(): bool {\n", " return $this === self::Issue;\n", " }\n", "}\n", );
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 3, 9, "Ticket").await;
assert!(
edit.is_some(),
"Expected a workspace edit for enum case rename from declaration"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
assert!(
file_edits.len() >= 2,
"Expected at least 2 edits for Issue → Ticket, got {}",
file_edits.len()
);
for te in &file_edits {
assert_eq!(te.new_text, "Ticket");
}
let result = apply_edits(text, &file_edits);
assert!(
result.contains("case Ticket"),
"Declaration should be renamed: {}",
result
);
assert!(
result.contains("self::Ticket"),
"Reference should be renamed: {}",
result
);
assert!(
!result.contains("case Issue"),
"Old declaration should not remain: {}",
result
);
assert!(
!result.contains("self::Issue"),
"Old reference should not remain: {}",
result
);
}
#[tokio::test]
async fn rename_enum_case_from_reference() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n", "enum TaskType: int {\n", " case Task = 1;\n", " case Issue = 2;\n", " public function isIssue(): bool {\n", " return $this === self::Issue;\n", " }\n", "}\n", );
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 5, 36, "Ticket").await;
assert!(
edit.is_some(),
"Expected a workspace edit for enum case rename from reference"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
assert!(
file_edits.len() >= 2,
"Expected at least 2 edits for Issue → Ticket, got {}",
file_edits.len()
);
for te in &file_edits {
assert_eq!(te.new_text, "Ticket");
}
let result = apply_edits(text, &file_edits);
assert!(
result.contains("case Ticket"),
"Declaration should be renamed: {}",
result
);
assert!(
result.contains("self::Ticket"),
"Reference should be renamed: {}",
result
);
}
#[tokio::test]
async fn rename_enum_case_does_not_affect_other_cases() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n", "enum TaskType: int {\n", " case Task = 1;\n", " case Issue = 2;\n", " public function isIssue(): bool {\n", " return $this === self::Issue;\n", " }\n", " public function isTask(): bool {\n", " return $this === self::Task;\n", " }\n", "}\n", );
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 3, 9, "Ticket").await;
assert!(
edit.is_some(),
"Expected a workspace edit for enum case rename"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("case Task"),
"Other enum case 'Task' should not be affected: {}",
result
);
assert!(
result.contains("self::Task"),
"Other enum case reference 'self::Task' should not be affected: {}",
result
);
}
#[tokio::test]
async fn rename_unit_enum_case() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n", "enum Color {\n", " case Red;\n", " case Blue;\n", "}\n", "function demo(): void {\n", " $c = Color::Red;\n", "}\n", );
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 2, 9, "Crimson").await;
assert!(
edit.is_some(),
"Expected a workspace edit for unit enum case rename"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
assert!(
file_edits.len() >= 2,
"Expected at least 2 edits for Red → Crimson, got {}",
file_edits.len()
);
for te in &file_edits {
assert_eq!(te.new_text, "Crimson");
}
let result = apply_edits(text, &file_edits);
assert!(
result.contains("case Crimson"),
"Declaration should be renamed: {}",
result
);
assert!(
result.contains("Color::Crimson"),
"Reference should be renamed: {}",
result
);
}
#[tokio::test]
async fn rename_closure_parameter_from_param() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Factory {\n",
" public function build(): void {\n",
" $this->afterMaking(function (Box $item): void {\n",
" $item->item_id ??= $item->segment_id\n",
" ? $item->segment->run->item_id\n",
" : Item::randomOrFactoryCreate()->getKey();\n",
" });\n",
" }\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 3, 44, "$box").await;
assert!(
edit.is_some(),
"Expected a workspace edit for closure parameter rename"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("function (Box $box)"),
"Parameter declaration not renamed: {}",
result
);
assert!(
result.contains("$box->item_id"),
"Body usage not renamed: {}",
result
);
assert!(
result.contains("$box->segment_id"),
"Body usage not renamed: {}",
result
);
assert!(
result.contains("$box->segment->run"),
"Chained body usage not renamed: {}",
result
);
assert!(
!result.contains("$item"),
"Old variable name still present: {}",
result
);
}
#[tokio::test]
async fn rename_closure_parameter_from_body_usage() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Factory {\n",
" public function build(): void {\n",
" $this->afterMaking(function (Box $item): void {\n",
" $item->name = 'test';\n",
" echo $item->name;\n",
" });\n",
" }\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 4, 13, "$box").await;
assert!(
edit.is_some(),
"Expected a workspace edit when renaming from body usage"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("function (Box $box)"),
"Parameter declaration not renamed: {}",
result
);
assert!(
result.contains("$box->name = 'test'"),
"Assignment usage not renamed: {}",
result
);
assert!(
result.contains("echo $box->name"),
"Echo usage not renamed: {}",
result
);
assert!(
!result.contains("$item"),
"Old variable name still present: {}",
result
);
}
#[tokio::test]
async fn rename_function_parameter_from_param() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"function greet(string $name): string {\n",
" return 'Hello, ' . $name . '!';\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 1, 23, "$who").await;
assert!(
edit.is_some(),
"Expected a workspace edit for function parameter rename"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("string $who)"),
"Parameter not renamed: {}",
result
);
assert!(
result.contains("$who . '!'"),
"Body usage not renamed: {}",
result
);
assert!(
!result.contains("$name"),
"Old variable name still present: {}",
result
);
}
#[tokio::test]
async fn rename_method_parameter_from_param() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Greeter {\n",
" public function greet(string $name): string {\n",
" return 'Hello, ' . $name . '!';\n",
" }\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 2, 35, "$who").await;
assert!(
edit.is_some(),
"Expected a workspace edit for method parameter rename"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("string $who)"),
"Parameter not renamed: {}",
result
);
assert!(
result.contains("$who . '!'"),
"Body usage not renamed: {}",
result
);
assert!(
!result.contains("$name"),
"Old variable name still present: {}",
result
);
}
#[tokio::test]
async fn rename_parameter_includes_docblock_param_tag() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Greeter {\n",
" /**\n",
" * @param string $name The person's name.\n",
" */\n",
" public function greet(string $name): string {\n",
" return 'Hello, ' . $name . '!';\n",
" }\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 5, 35, "$who").await;
assert!(
edit.is_some(),
"Expected a workspace edit for parameter rename with docblock"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("@param string $who"),
"Docblock @param not renamed: {}",
result
);
assert!(
result.contains("string $who)"),
"Parameter not renamed: {}",
result
);
assert!(
result.contains("$who . '!'"),
"Body usage not renamed: {}",
result
);
assert!(
!result.contains("$name"),
"Old variable name still present: {}",
result
);
}
#[tokio::test]
async fn rename_parameter_includes_docblock_from_body_usage() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @param string $name The person's name.\n",
" */\n",
"function greet(string $name): string {\n",
" return 'Hello, ' . $name . '!';\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 5, 24, "$who").await;
assert!(
edit.is_some(),
"Expected a workspace edit for parameter rename from body"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("@param string $who"),
"Docblock @param not renamed: {}",
result
);
assert!(
result.contains("string $who)"),
"Parameter not renamed: {}",
result
);
assert!(
result.contains("$who . '!'"),
"Body usage not renamed: {}",
result
);
assert!(
!result.contains("$name"),
"Old variable name still present: {}",
result
);
}
#[tokio::test]
async fn rename_parameter_multiple_docblock_params() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"class Math {\n",
" /**\n",
" * @param int $a First operand.\n",
" * @param int $b Second operand.\n",
" */\n",
" public function add(int $a, int $b): int {\n",
" return $a + $b;\n",
" }\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 6, 29, "$x").await;
assert!(edit.is_some());
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("@param int $x First"),
"Docblock @param for $a not renamed: {}",
result
);
assert!(
result.contains("@param int $b Second"),
"Docblock @param for $b was wrongly renamed: {}",
result
);
assert!(
result.contains("int $x, int $b)"),
"Parameter $a not renamed: {}",
result
);
assert!(
result.contains("return $x + $b"),
"Body usage not renamed correctly: {}",
result
);
}
#[tokio::test]
async fn rename_arrow_function_parameter() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"function demo(): void {\n",
" $fn = fn(int $x) => $x * 2;\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 2, 18, "$n").await;
assert!(
edit.is_some(),
"Expected a workspace edit for arrow function parameter rename"
);
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("fn(int $n)"),
"Arrow function parameter not renamed: {}",
result
);
assert!(
result.contains("$n * 2"),
"Arrow function body not renamed: {}",
result
);
assert!(
!result.contains("$x"),
"Old variable name still present: {}",
result
);
}
#[tokio::test]
async fn rename_closure_parameter_does_not_leak_to_outer_scope() {
let backend = Backend::new_test();
let uri = Url::parse("file:///test.php").unwrap();
let text = concat!(
"<?php\n",
"function demo(): void {\n",
" $item = 'outer';\n",
" $fn = function (string $item): string {\n",
" return $item . '!';\n",
" };\n",
" echo $item;\n",
"}\n",
);
open_file(&backend, &uri, text).await;
let edit = rename(&backend, &uri, 3, 28, "$inner").await;
assert!(edit.is_some());
let file_edits = edits_for_uri(&edit.unwrap(), &uri);
let result = apply_edits(text, &file_edits);
assert!(
result.contains("function (string $inner)"),
"Closure parameter not renamed: {}",
result
);
assert!(
result.contains("return $inner . '!'"),
"Closure body not renamed: {}",
result
);
assert!(
result.contains("$item = 'outer'"),
"Outer scope was wrongly renamed: {}",
result
);
assert!(
result.contains("echo $item"),
"Outer scope echo was wrongly renamed: {}",
result
);
}