use crate::common::create_test_backend;
use tower_lsp::lsp_types::*;
fn get_code_actions(
backend: &phpantom_lsp::Backend,
uri: &str,
content: &str,
line: u32,
character: u32,
) -> Vec<CodeActionOrCommand> {
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(line, character),
end: Position::new(line, character),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: WorkDoneProgressParams {
work_done_token: None,
},
partial_result_params: PartialResultParams {
partial_result_token: None,
},
};
backend.handle_code_action(uri, content, ¶ms)
}
fn find_promote_action(actions: &[CodeActionOrCommand]) -> Option<&CodeAction> {
actions.iter().find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title == "Promote to constructor property" => {
Some(ca)
}
_ => None,
})
}
fn apply_edit(content: &str, edit: &WorkspaceEdit) -> String {
let changes = edit.changes.as_ref().expect("edit should have changes");
let edits = changes
.values()
.next()
.expect("should have edits for one URI");
let mut sorted: Vec<&TextEdit> = edits.iter().collect();
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 mut result = content.to_string();
for edit in sorted {
let start = position_to_offset(&result, edit.range.start);
let end = position_to_offset(&result, edit.range.end);
result.replace_range(start..end, &edit.new_text);
}
result
}
fn position_to_offset(content: &str, pos: Position) -> usize {
let mut offset = 0;
for (i, line) in content.lines().enumerate() {
if i == pos.line as usize {
return offset + pos.character as usize;
}
offset += line.len() + 1; }
offset
}
#[test]
fn promotes_private_property() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
class Foo {
private string $name;
public function __construct(string $name) {
$this->name = $name;
}
}
";
let actions = get_code_actions(&backend, uri, content, 4, 35);
let action = find_promote_action(&actions).expect("should offer promote action");
let result = apply_edit(content, action.edit.as_ref().unwrap());
assert!(
result.contains("private string $name)"),
"parameter should have private visibility: {result}"
);
assert!(
!result.contains("private string $name;"),
"property declaration should be removed: {result}"
);
assert!(
!result.contains("$this->name = $name;"),
"assignment should be removed: {result}"
);
}
#[test]
fn promotes_protected_property() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
class Foo {
protected int $age;
public function __construct(int $age) {
$this->age = $age;
}
}
";
let actions = get_code_actions(&backend, uri, content, 4, 35);
let action = find_promote_action(&actions).expect("should offer promote action");
let result = apply_edit(content, action.edit.as_ref().unwrap());
assert!(
result.contains("protected int $age)"),
"should use protected: {result}"
);
}
#[test]
fn promotes_readonly_property() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
class Foo {
private readonly string $name;
public function __construct(string $name) {
$this->name = $name;
}
}
";
let actions = get_code_actions(&backend, uri, content, 4, 35);
let action = find_promote_action(&actions).expect("should offer promote action");
let result = apply_edit(content, action.edit.as_ref().unwrap());
assert!(
result.contains("private readonly string $name)"),
"should include readonly: {result}"
);
}
#[test]
fn carries_over_default_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
class Foo {
private string $status = 'active';
public function __construct(string $status) {
$this->status = $status;
}
}
";
let actions = get_code_actions(&backend, uri, content, 4, 35);
let action = find_promote_action(&actions).expect("should offer promote action");
let result = apply_edit(content, action.edit.as_ref().unwrap());
assert!(
result.contains("private string $status = 'active')"),
"should carry default value: {result}"
);
}
#[test]
fn no_action_for_non_constructor() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
class Foo {
private string $name;
public function setName(string $name): void {
$this->name = $name;
}
}
";
let actions = get_code_actions(&backend, uri, content, 4, 35);
let action = find_promote_action(&actions);
assert!(action.is_none(), "should not offer for non-constructor");
}
#[test]
fn no_action_for_already_promoted() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
class Foo {
public function __construct(private string $name) {}
}
";
let actions = get_code_actions(&backend, uri, content, 2, 40);
let action = find_promote_action(&actions);
assert!(action.is_none(), "should not offer for already-promoted");
}
#[test]
fn no_action_when_no_matching_property() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
class Foo {
public function __construct(string $name) {
echo $name;
}
}
";
let actions = get_code_actions(&backend, uri, content, 2, 35);
let action = find_promote_action(&actions);
assert!(
action.is_none(),
"should not offer when no matching property"
);
}
#[test]
fn no_action_when_param_used_elsewhere() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
class Foo {
private string $name;
public function __construct(string $name) {
$this->name = $name;
echo $name;
}
}
";
let actions = get_code_actions(&backend, uri, content, 4, 35);
let action = find_promote_action(&actions);
assert!(
action.is_none(),
"should not offer when param used elsewhere"
);
}
#[test]
fn no_action_for_static_property() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
class Foo {
private static string $name;
public function __construct(string $name) {
$this->name = $name;
}
}
";
let actions = get_code_actions(&backend, uri, content, 4, 35);
let action = find_promote_action(&actions);
assert!(action.is_none(), "should not offer for static property");
}
#[test]
fn promotes_only_targeted_parameter() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
class Foo {
private string $name;
private int $age;
public function __construct(string $name, int $age) {
$this->name = $name;
$this->age = $age;
}
}
";
let actions = get_code_actions(&backend, uri, content, 5, 50);
let action = find_promote_action(&actions).expect("should offer promote for $age");
let result = apply_edit(content, action.edit.as_ref().unwrap());
assert!(
result.contains("private int $age)"),
"$age should be promoted: {result}"
);
assert!(
result.contains("private string $name;"),
"$name property should remain: {result}"
);
assert!(
result.contains("$this->name = $name;"),
"$name assignment should remain: {result}"
);
}
#[test]
fn works_in_namespace() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
namespace App\\Models;
class User {
private string $email;
public function __construct(string $email) {
$this->email = $email;
}
}
";
let actions = get_code_actions(&backend, uri, content, 6, 35);
let action = find_promote_action(&actions).expect("should work in namespace");
let result = apply_edit(content, action.edit.as_ref().unwrap());
assert!(
result.contains("private string $email)"),
"should promote in namespace: {result}"
);
}
#[test]
fn promotes_with_union_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
class Foo {
private int|string $id;
public function __construct(int|string $id) {
$this->id = $id;
}
}
";
let actions = get_code_actions(&backend, uri, content, 4, 35);
let action = find_promote_action(&actions).expect("should handle union types");
let result = apply_edit(content, action.edit.as_ref().unwrap());
assert!(
result.contains("private int|string $id)"),
"should preserve union type: {result}"
);
}
#[test]
fn promotes_with_nullable_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
class Foo {
private ?string $name;
public function __construct(?string $name) {
$this->name = $name;
}
}
";
let actions = get_code_actions(&backend, uri, content, 4, 35);
let action = find_promote_action(&actions).expect("should handle nullable types");
let result = apply_edit(content, action.edit.as_ref().unwrap());
assert!(
result.contains("private ?string $name)"),
"should preserve nullable type: {result}"
);
}
#[test]
fn action_has_correct_kind() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = "\
<?php
class Foo {
private string $name;
public function __construct(string $name) {
$this->name = $name;
}
}
";
let actions = get_code_actions(&backend, uri, content, 4, 35);
let action = find_promote_action(&actions).expect("should offer promote action");
assert_eq!(
action.kind,
Some(CodeActionKind::new("refactor.rewrite")),
"should be a refactor.rewrite action"
);
}