use crate::common::{create_psr4_workspace, create_test_backend};
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;
#[tokio::test]
async fn test_completion_includes_properties() {
let backend = create_test_backend();
let uri = Url::parse("file:///props.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public string $name;\n",
" public int $age;\n",
" function login() {}\n",
" function logout() {}\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
)
.to_string();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text,
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_items: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.collect();
let property_items: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.collect();
assert_eq!(method_items.len(), 3, "Should have 3 methods");
assert_eq!(property_items.len(), 2, "Should have 2 properties");
let prop_labels: Vec<&str> = property_items.iter().map(|i| i.label.as_str()).collect();
assert!(
prop_labels.contains(&"name"),
"Should contain property 'name'"
);
assert!(
prop_labels.contains(&"age"),
"Should contain property 'age'"
);
let name_item = property_items.iter().find(|i| i.label == "name").unwrap();
assert_eq!(
name_item.insert_text.as_deref(),
Some("name"),
"Property insert_text should be 'name' without $"
);
let name_detail = name_item.detail.as_deref().unwrap();
assert!(
name_detail.contains("string"),
"Property detail '{}' should include type hint 'string'",
name_detail
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_property_without_type_hint() {
let backend = create_test_backend();
let uri = Url::parse("file:///untyped.php").unwrap();
let text = concat!(
"<?php\n",
"class Bag {\n",
" public $stuff;\n",
" function get() {\n",
" $this->\n",
" }\n",
"}\n",
)
.to_string();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text,
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 4,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
match result.unwrap() {
CompletionResponse::Array(items) => {
let property_items: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.collect();
assert_eq!(property_items.len(), 1);
assert_eq!(property_items[0].label, "stuff");
assert!(
property_items[0].detail.is_none(),
"Untyped property should have no detail, got: {:?}",
property_items[0].detail
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_class_with_only_properties() {
let backend = create_test_backend();
let uri = Url::parse("file:///onlyprops.php").unwrap();
let text = concat!(
"<?php\n",
"class Data {\n",
" public string $name;\n",
" public int $value;\n",
" function test() {\n",
" $this->\n",
" }\n",
"}\n",
)
.to_string();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text,
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 5,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
match result.unwrap() {
CompletionResponse::Array(items) => {
let property_items: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.collect();
assert_eq!(
property_items.len(),
2,
"Should return 2 property completions"
);
let labels: Vec<&str> = property_items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"name"));
assert!(labels.contains(&"value"));
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_constant_detail_with_type_hint() {
let backend = create_test_backend();
let uri = Url::parse("file:///consttype.php").unwrap();
let text = concat!(
"<?php\n",
"class Cfg {\n",
" const string LABEL = 'hello';\n",
" const COUNT = 5;\n",
" function f() {\n",
" self::\n",
" }\n",
"}\n",
);
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;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 5,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some());
match result.unwrap() {
CompletionResponse::Array(items) => {
let constants: Vec<&CompletionItem> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.collect();
assert_eq!(constants.len(), 2, "Should have 2 constants");
let label_const = constants.iter().find(|c| c.label == "LABEL").unwrap();
assert_eq!(
label_const.detail.as_deref(),
Some("'hello'"),
"LABEL detail should show the value, got: {:?}",
label_const.detail
);
let count_const = constants.iter().find(|c| c.label == "COUNT").unwrap();
assert_eq!(
count_const.detail.as_deref(),
Some("5"),
"COUNT detail should show the value, got: {:?}",
count_const.detail
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_promoted_properties_appear_in_this() {
let backend = create_test_backend();
let uri = Url::parse("file:///promoted.php").unwrap();
let text = concat!(
"<?php\n",
"class ShoppingCartService {\n",
" private IShoppingCart $regular;\n",
"\n",
" public function __construct(\n",
" private IShoppingCart $promoted,\n",
" ) {}\n",
"\n",
" public function doWork(): void {\n",
" $this->\n",
" }\n",
"}\n",
);
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;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 9,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some(), "Completion should return results");
match result.unwrap() {
CompletionResponse::Array(items) => {
let names: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
names.contains(&"regular"),
"Should contain regular property 'regular', got: {:?}",
names
);
assert!(
names.contains(&"promoted"),
"Should contain promoted property 'promoted', got: {:?}",
names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_private_property_hidden_outside_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///vis_private_hidden.php").unwrap();
let text = concat!(
"<?php\n",
"class Container {\n",
" private array $bindings = [];\n",
" public function bind(string $k, object $v): void {}\n",
" public function getStatus(): int { return 0; }\n",
"}\n",
"$c = new Container();\n",
"$c->\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 4,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completions");
match result.unwrap() {
CompletionResponse::Array(items) => {
let names: Vec<&str> = items
.iter()
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
names.contains(&"bind"),
"Should include public method 'bind', got: {:?}",
names
);
assert!(
names.contains(&"getStatus"),
"Should include public method 'getStatus', got: {:?}",
names
);
assert!(
!names.contains(&"bindings"),
"Should NOT include private property 'bindings', got: {:?}",
names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_private_method_hidden_outside_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///vis_private_method_hidden.php").unwrap();
let text = concat!(
"<?php\n",
"class Service {\n",
" private function internalHelper(): void {}\n",
" protected function onSetup(): void {}\n",
" public function run(): void {}\n",
"}\n",
"$svc = new Service();\n",
"$svc->\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 6,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completions");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"run"),
"Should include public method 'run', got: {:?}",
method_names
);
assert!(
!method_names.contains(&"internalHelper"),
"Should NOT include private method 'internalHelper', got: {:?}",
method_names
);
assert!(
!method_names.contains(&"onSetup"),
"Should NOT include protected method 'onSetup' from top-level, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_private_and_protected_visible_inside_own_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///vis_private_visible.php").unwrap();
let text = concat!(
"<?php\n",
"class Vault {\n",
" private string $secret = 'x';\n",
" protected int $level = 1;\n",
" public string $name = 'vault';\n",
" private function decrypt(): string { return ''; }\n",
" protected function validate(): bool { return true; }\n",
" public function open(): void {\n",
" $this->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 8,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completions");
match result.unwrap() {
CompletionResponse::Array(items) => {
let names: Vec<&str> = items
.iter()
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
names.contains(&"secret"),
"Should include private property 'secret' via $this->, got: {:?}",
names
);
assert!(
names.contains(&"level"),
"Should include protected property 'level' via $this->, got: {:?}",
names
);
assert!(
names.contains(&"name"),
"Should include public property 'name' via $this->, got: {:?}",
names
);
assert!(
names.contains(&"decrypt"),
"Should include private method 'decrypt' via $this->, got: {:?}",
names
);
assert!(
names.contains(&"validate"),
"Should include protected method 'validate' via $this->, got: {:?}",
names
);
assert!(
names.contains(&"open"),
"Should include public method 'open' via $this->, got: {:?}",
names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_private_visible_on_same_class_variable() {
let backend = create_test_backend();
let uri = Url::parse("file:///vis_same_class_var.php").unwrap();
let text = concat!(
"<?php\n",
"class Node {\n",
" private int $id;\n",
" public string $label;\n",
" public function merge(Node $other): void {\n",
" $other->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 5,
character: 16,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completions");
match result.unwrap() {
CompletionResponse::Array(items) => {
let names: Vec<&str> = items
.iter()
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
names.contains(&"id"),
"Should include private 'id' on same-class variable, got: {:?}",
names
);
assert!(
names.contains(&"label"),
"Should include public 'label', got: {:?}",
names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_protected_visible_from_different_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///vis_protected_subclass.php").unwrap();
let text = concat!(
"<?php\n",
"class Base {\n",
" private function secret(): void {}\n",
" protected function hook(): void {}\n",
" public function run(): void {}\n",
"}\n",
"class Child extends Base {\n",
" public function doWork(): void {\n",
" $b = new Base();\n",
" $b->\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 9,
character: 12,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completions");
match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
method_names.contains(&"run"),
"Should include public 'run', got: {:?}",
method_names
);
assert!(
method_names.contains(&"hook"),
"Should include protected 'hook' from inside another class, got: {:?}",
method_names
);
assert!(
!method_names.contains(&"secret"),
"Should NOT include private 'secret' from different class, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_private_constant_hidden_outside_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///vis_const_hidden.php").unwrap();
let text = concat!(
"<?php\n",
"class Config {\n",
" private const SECRET_KEY = 'abc';\n",
" protected const INTERNAL_VER = 2;\n",
" public const VERSION = '1.0';\n",
" public static function create(): void {}\n",
"}\n",
"Config::\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 8,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completions");
match result.unwrap() {
CompletionResponse::Array(items) => {
let const_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
const_names.contains(&"VERSION"),
"Should include public constant 'VERSION', got: {:?}",
const_names
);
assert!(
!const_names.contains(&"SECRET_KEY"),
"Should NOT include private constant 'SECRET_KEY', got: {:?}",
const_names
);
assert!(
!const_names.contains(&"INTERNAL_VER"),
"Should NOT include protected constant 'INTERNAL_VER' from top-level, got: {:?}",
const_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_private_constant_visible_via_self() {
let backend = create_test_backend();
let uri = Url::parse("file:///vis_const_self.php").unwrap();
let text = concat!(
"<?php\n",
"class Config {\n",
" private const SECRET_KEY = 'abc';\n",
" protected const INTERNAL_VER = 2;\n",
" public const VERSION = '1.0';\n",
" public function check(): void {\n",
" self::\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 6,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completions");
match result.unwrap() {
CompletionResponse::Array(items) => {
let const_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::CONSTANT))
.map(|i| i.filter_text.as_deref().unwrap())
.collect();
assert!(
const_names.contains(&"VERSION"),
"Should include public constant 'VERSION', got: {:?}",
const_names
);
assert!(
const_names.contains(&"SECRET_KEY"),
"Should include private constant 'SECRET_KEY' via self::, got: {:?}",
const_names
);
assert!(
const_names.contains(&"INTERNAL_VER"),
"Should include protected constant 'INTERNAL_VER' via self::, got: {:?}",
const_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_private_hidden_cross_file() {
let (backend, _dir) = create_psr4_workspace(
r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#,
&[(
"src/Repo.php",
concat!(
"<?php\n",
"namespace App;\n",
"class Repo {\n",
" private array $cache = [];\n",
" protected string $table = '';\n",
" public function find(int $id): void {}\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///vis_cross_file.php").unwrap();
let text = concat!(
"<?php\n",
"use App\\Repo;\n",
"$repo = new Repo();\n",
"$repo->\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 3,
character: 7,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(result.is_some(), "Should return completions");
match result.unwrap() {
CompletionResponse::Array(items) => {
let names: Vec<&str> = items
.iter()
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
names.contains(&"find"),
"Should include public method 'find', got: {:?}",
names
);
assert!(
!names.contains(&"cache"),
"Should NOT include private property 'cache', got: {:?}",
names
);
assert!(
!names.contains(&"table"),
"Should NOT include protected property 'table' from top-level, got: {:?}",
names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_promoted_property_type_resolves_for_chaining() {
let backend = create_test_backend();
let uri = Url::parse("file:///promoted_chain.php").unwrap();
let text = concat!(
"<?php\n",
"class Logger {\n",
" public function info(string $msg): void {}\n",
" public function error(string $msg): void {}\n",
"}\n",
"class Service {\n",
" public function __construct(\n",
" private Logger $logger,\n",
" ) {}\n",
"\n",
" public function run(): void {\n",
" $this->logger->\n",
" }\n",
"}\n",
);
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;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 24,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve promoted property type for chaining"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let names: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
names.iter().any(|n| n.starts_with("info(")),
"Should contain Logger method 'info', got: {:?}",
names
);
assert!(
names.iter().any(|n| n.starts_with("error(")),
"Should contain Logger method 'error', got: {:?}",
names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_promoted_property_param_override_basic() {
let backend = create_test_backend();
let uri = Url::parse("file:///promoted_param_override.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public int $id;\n",
" public string $email;\n",
"}\n",
"class UserService {\n",
" /**\n",
" * @param list<User> $users\n",
" */\n",
" public function __construct(\n",
" public array $users,\n",
" ) {}\n",
"\n",
" public function demo() {\n",
" $this->users\n",
" }\n",
"}\n",
);
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;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 14,
character: 20,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should return results for $this->users"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.starts_with("users")),
"Should include promoted property 'users'. Got: {:?}",
labels
);
}
CompletionResponse::List(_) => panic!("Expected Array response"),
}
}
#[tokio::test]
async fn test_completion_promoted_property_param_override_generic_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///promoted_generic_override.php").unwrap();
let text = concat!(
"<?php\n",
"class Order {\n",
" public int $id;\n",
"}\n",
"class Collection {\n",
" public function first(): mixed {}\n",
" public function count(): int {}\n",
"}\n",
"class OrderService {\n",
" /**\n",
" * @param Collection<int, Order> $orders\n",
" */\n",
" public function __construct(\n",
" public object $orders,\n",
" ) {}\n",
"\n",
" public function demo() {\n",
" $this->orders->\n",
" }\n",
"}\n",
);
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;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 17,
character: 23,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should return results for @param overridden $this->orders->"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.starts_with("first")),
"Should include 'first' from Collection via @param override. Got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.starts_with("count")),
"Should include 'count' from Collection via @param override. Got: {:?}",
labels
);
}
CompletionResponse::List(_) => panic!("Expected Array response"),
}
}
#[tokio::test]
async fn test_completion_promoted_property_param_scalar_not_overridden() {
let backend = create_test_backend();
let uri = Url::parse("file:///promoted_scalar_no_override.php").unwrap();
let text = concat!(
"<?php\n",
"class UserId {\n",
" public function getValue(): int {}\n",
"}\n",
"class Service {\n",
" /**\n",
" * @param UserId $id\n",
" */\n",
" public function __construct(\n",
" public int $id,\n",
" ) {}\n",
"\n",
" public function demo() {\n",
" $this->id->\n",
" }\n",
"}\n",
);
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;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 13,
character: 19,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_none(),
"Scalar promoted property should not resolve to class, got: {:?}",
result
);
}
#[tokio::test]
async fn test_completion_promoted_property_param_override_with_var_chain() {
let backend = create_test_backend();
let uri = Url::parse("file:///promoted_var_chain.php").unwrap();
let text = concat!(
"<?php\n",
"class Logger {\n",
" public function info(string $msg): void {}\n",
" public function error(string $msg): void {}\n",
"}\n",
"class App {\n",
" /**\n",
" * @param Logger $logger\n",
" */\n",
" public function __construct(\n",
" public object $logger,\n",
" ) {}\n",
"}\n",
"function demo() {\n",
" $app = new App(new Logger());\n",
" $app->logger->\n",
"}\n",
);
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;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 15,
character: 20,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should return results for $app->logger-> via @param override"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.starts_with("info")),
"Should include 'info' from Logger via @param override + property chain. Got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.starts_with("error")),
"Should include 'error' from Logger via @param override + property chain. Got: {:?}",
labels
);
}
CompletionResponse::List(_) => panic!("Expected Array response"),
}
}
#[tokio::test]
async fn test_completion_promoted_property_param_override_cross_file() {
let composer = r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#;
let cache_php = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"class Cache {\n",
" public function get(string $key): mixed {}\n",
" public function set(string $key, mixed $val): void {}\n",
"}\n",
);
let service_php = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"class Service {\n",
" /**\n",
" * @param Cache $cache\n",
" */\n",
" public function __construct(\n",
" public object $cache,\n",
" ) {}\n",
"\n",
" public function demo() {\n",
" $this->cache->\n",
" }\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer,
&[
("src/Cache.php", cache_php),
("src/Service.php", service_php),
],
);
let uri = Url::parse("file:///src/Service.php").unwrap();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: service_php.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 12,
character: 22,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should return results for cross-file @param promoted $this->cache->"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.starts_with("get")),
"Should include 'get' from cross-file Cache via @param override. Got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.starts_with("set")),
"Should include 'set' from cross-file Cache via @param override. Got: {:?}",
labels
);
}
CompletionResponse::List(_) => panic!("Expected Array response"),
}
}
#[tokio::test]
async fn test_completion_promoted_property_inline_var_docblock() {
let backend = create_test_backend();
let uri = Url::parse("file:///inline_var.php").unwrap();
let text = concat!(
"<?php\n",
"class EventModel {\n",
" public string $timestamp;\n",
" public function getLabel(): string { return ''; }\n",
"}\n",
"class DataModel {\n",
" public function __construct(\n",
" /** @var array<EventModel> */\n",
" public array $events,\n",
" ) {}\n",
" public function test(): void {\n",
" foreach ($this->events as $event) {\n",
" $event->\n",
" }\n",
" }\n",
"}\n",
);
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;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 12,
character: 20,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve inline @var type on promoted property"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.starts_with("getLabel")),
"Should include 'getLabel' from EventModel via inline @var. Got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l == &"timestamp"),
"Should include 'timestamp' property from EventModel via inline @var. Got: {:?}",
labels
);
}
CompletionResponse::List(_) => panic!("Expected Array response"),
}
}
#[tokio::test]
async fn test_completion_promoted_property_inline_var_union_native_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///inline_var_union.php").unwrap();
let text = concat!(
"<?php\n",
"class Optional {}\n",
"class UserModel {\n",
" public string $name;\n",
" public function greet(): string { return ''; }\n",
"}\n",
"class Container {\n",
" public function __construct(\n",
" /** @var array<UserModel> */\n",
" public array|Optional $users,\n",
" ) {}\n",
" public function demo(): void {\n",
" foreach ($this->users as $user) {\n",
" $user->\n",
" }\n",
" }\n",
"}\n",
);
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;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 13,
character: 20,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve inline @var on union-typed promoted property"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.starts_with("greet")),
"Should include 'greet' from UserModel via inline @var on union type. Got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l == &"name"),
"Should include 'name' property from UserModel via inline @var on union type. Got: {:?}",
labels
);
}
CompletionResponse::List(_) => panic!("Expected Array response"),
}
}
#[tokio::test]
async fn test_completion_phpdoc_property_type_resolves_for_chaining_on_parameter() {
let backend = create_test_backend();
let uri = Url::parse("file:///phpdoc_prop_chain.php").unwrap();
let text = concat!(
"<?php\n",
"class Carbon {\n",
" public function format(string $fmt): string { return ''; }\n",
" public function diffForHumans(): string { return ''; }\n",
"}\n",
"/**\n",
" * @property Carbon $created\n",
" */\n",
"class Supplyvaluelog {\n",
"}\n",
"class Controller {\n",
" public function index(Supplyvaluelog $supplyValueLog): void {\n",
" $supplyValueLog->created->\n",
" }\n",
"}\n",
);
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;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 12,
character: 34,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve @property type for chaining on parameter variable"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let names: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
names.iter().any(|n| n.starts_with("format(")),
"Should contain Carbon method 'format' via @property chain on parameter. Got: {:?}",
names
);
assert!(
names.iter().any(|n| n.starts_with("diffForHumans(")),
"Should contain Carbon method 'diffForHumans' via @property chain on parameter. Got: {:?}",
names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_phpdoc_property_type_resolves_with_parent_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///phpdoc_prop_parent.php").unwrap();
let text = concat!(
"<?php\n",
"class Carbon {\n",
" public function format(string $fmt): string { return ''; }\n",
" public function diffForHumans(): string { return ''; }\n",
"}\n",
"class Model {\n",
" public static function find(int $id): static { return new static(); }\n",
"}\n",
"/**\n",
" * @property Carbon $created\n",
" */\n",
"final class Supplyvaluelog extends Model {\n",
"}\n",
"class Controller {\n",
" public function index(Supplyvaluelog $supplyValueLog): void {\n",
" $supplyValueLog->created->\n",
" }\n",
"}\n",
);
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;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 15,
character: 34,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve @property type for chaining when class extends parent"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let names: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
names.iter().any(|n| n.starts_with("format(")),
"Should contain Carbon method 'format' via @property chain with parent class. Got: {:?}",
names
);
assert!(
names.iter().any(|n| n.starts_with("diffForHumans(")),
"Should contain Carbon method 'diffForHumans' via @property chain with parent class. Got: {:?}",
names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_phpdoc_property_type_cross_file_parameter_chain() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\Models\\": "src/Models/",
"App\\Http\\Controllers\\": "src/Http/Controllers/",
"Carbon\\": "src/Carbon/",
"Illuminate\\Database\\Eloquent\\": "src/Illuminate/"
}
}
}"#;
let carbon_php = "\
<?php
namespace Carbon;
class Carbon {
public function format(string $fmt): string { return ''; }
public function diffForHumans(): string { return ''; }
public function addDays(int $n): static { return $this; }
}
";
let base_model_php = "\
<?php
namespace Illuminate\\Database\\Eloquent;
class Model {
public static function find(int $id): static { return new static(); }
public function save(): bool { return true; }
}
";
let model_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
/**
* @property \\Carbon\\Carbon $created
* @property string $name
*/
final class Supplyvaluelog extends Model {
}
";
let controller_php = "\
<?php
namespace App\\Http\\Controllers;
use App\\Models\\Supplyvaluelog;
class Controller {
public function index(Supplyvaluelog $supplyValueLog): void {
$supplyValueLog->created->
}
}
";
let files: Vec<(&str, &str)> = vec![
("src/Carbon/Carbon.php", carbon_php),
("src/Illuminate/Model.php", base_model_php),
("src/Models/Supplyvaluelog.php", model_php),
("src/Http/Controllers/Controller.php", controller_php),
];
let (backend, _dir) = create_psr4_workspace(composer_json, &files);
let uri = Url::from_file_path(_dir.path().join("src/Http/Controllers/Controller.php")).unwrap();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: controller_php.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 5,
character: 34,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve cross-file @property type for chaining on parameter"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let names: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
names.iter().any(|n| n.starts_with("format(")),
"Should contain Carbon method 'format' via cross-file @property chain. Got: {:?}",
names
);
assert!(
names.iter().any(|n| n.starts_with("diffForHumans(")),
"Should contain Carbon method 'diffForHumans' via cross-file @property chain. Got: {:?}",
names
);
assert!(
names.iter().any(|n| n.starts_with("addDays(")),
"Should contain Carbon method 'addDays' via cross-file @property chain. Got: {:?}",
names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_completion_phpdoc_property_short_name_cross_file() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\Models\\": "src/Models/",
"App\\Http\\Controllers\\": "src/Http/Controllers/",
"Carbon\\": "src/Carbon/",
"Illuminate\\Database\\Eloquent\\": "src/Illuminate/"
}
}
}"#;
let carbon_php = "\
<?php
namespace Carbon;
class Carbon {
public function format(string $fmt): string { return ''; }
public function diffForHumans(): string { return ''; }
public function addDays(int $n): static { return $this; }
}
";
let base_model_php = "\
<?php
namespace Illuminate\\Database\\Eloquent;
class Model {
public static function find(int $id): static { return new static(); }
public function save(): bool { return true; }
}
";
let model_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Carbon\\Carbon;
/**
* @property Carbon $created
* @property string $name
*/
final class Supplyvaluelog extends Model {
}
";
let controller_php = "\
<?php
namespace App\\Http\\Controllers;
use App\\Models\\Supplyvaluelog;
class Controller {
public function index(Supplyvaluelog $supplyValueLog): void {
$supplyValueLog->created->
}
}
";
let files: Vec<(&str, &str)> = vec![
("src/Carbon/Carbon.php", carbon_php),
("src/Illuminate/Model.php", base_model_php),
("src/Models/Supplyvaluelog.php", model_php),
("src/Http/Controllers/Controller.php", controller_php),
];
let (backend, _dir) = create_psr4_workspace(composer_json, &files);
let uri = Url::from_file_path(_dir.path().join("src/Http/Controllers/Controller.php")).unwrap();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: controller_php.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 5,
character: 34,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve cross-file @property type even when short name is used"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let names: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
names.iter().any(|n| n.starts_with("format(")),
"Should contain Carbon method 'format' via short-name @property chain. Got: {:?}",
names
);
assert!(
names.iter().any(|n| n.starts_with("diffForHumans(")),
"Should contain Carbon method 'diffForHumans' via short-name @property chain. Got: {:?}",
names
);
assert!(
names.iter().any(|n| n.starts_with("addDays(")),
"Should contain Carbon method 'addDays' via short-name @property chain. Got: {:?}",
names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}