use crate::common::{create_psr4_workspace, create_test_backend};
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;
#[tokio::test]
async fn test_generic_extends_resolves_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_basic.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class Box {\n",
" /** @return T */\n",
" public function get() {}\n",
" /** @return void */\n",
" public function set() {}\n",
"}\n",
"\n",
"class Apple {\n",
" public function bite(): void {}\n",
" public function peel(): void {}\n",
"}\n",
"\n",
"/**\n",
" * @extends Box<Apple>\n",
" */\n",
"class AppleBox extends Box {\n",
"}\n",
"\n",
"function test() {\n",
" $box = new AppleBox();\n",
" $box->get()->\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: 24,
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_some(), "Completion should return results");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"bite"),
"Should resolve T to Apple and show Apple's 'bite' method, got: {:?}",
method_names
);
assert!(
method_names.contains(&"peel"),
"Should resolve T to Apple and show Apple's 'peel' method, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_extends_two_params_resolves() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_two_params.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first() {}\n",
" /** @return TValue|null */\n",
" public function last() {}\n",
"}\n",
"\n",
"class Language {\n",
" public int $priority;\n",
" public function getCode(): string {}\n",
"}\n",
"\n",
"/**\n",
" * @extends Collection<int, Language>\n",
" */\n",
"class LanguageCollection extends Collection {\n",
"}\n",
"\n",
"function test() {\n",
" $col = new LanguageCollection();\n",
" $col->first()->\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: 25,
character: 21,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getCode"),
"Should resolve TValue to Language and show 'getCode', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_template_covariant() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_covariant.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template TKey of array-key\n",
" * @template-covariant TValue\n",
" */\n",
"class TypedList {\n",
" /** @return TValue */\n",
" public function first() {}\n",
"}\n",
"\n",
"class User {\n",
" public function getName(): string {}\n",
" public function getEmail(): string {}\n",
"}\n",
"\n",
"/**\n",
" * @extends TypedList<int, User>\n",
" */\n",
"class UserList extends TypedList {\n",
"}\n",
"\n",
"function test() {\n",
" $list = new UserList();\n",
" $list->first()->\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: 23,
character: 21,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Should resolve TValue (covariant) to User, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getEmail"),
"Should resolve TValue (covariant) to User, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_child_own_methods_preserved() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_own_methods.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class GenericRepo {\n",
" /** @return T */\n",
" public function find() {}\n",
"}\n",
"\n",
"class Product {\n",
" public function getPrice(): float {}\n",
"}\n",
"\n",
"/**\n",
" * @extends GenericRepo<Product>\n",
" */\n",
"class ProductRepo extends GenericRepo {\n",
" public function findByCategory(string $cat): void {}\n",
" function test() {\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: 19,
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"findByCategory"),
"Should include own method 'findByCategory', got: {:?}",
method_names
);
assert!(
method_names.contains(&"find"),
"Should include inherited 'find' method, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_method_return_type_chain() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_chain.php").unwrap();
let text = concat!(
"<?php\n",
"class Order {\n",
" public function getTotal(): float {}\n",
" public function getStatus(): string {}\n",
"}\n",
"\n",
"/**\n",
" * @template T\n",
" */\n",
"class Repository {\n",
" /** @return T */\n",
" public function findFirst() {}\n",
"}\n",
"\n",
"/**\n",
" * @extends Repository<Order>\n",
" */\n",
"class OrderRepository extends Repository {\n",
"}\n",
"\n",
"class Service {\n",
" public function getRepo(): OrderRepository {}\n",
" function test() {\n",
" $this->getRepo()->findFirst()->\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: 23,
character: 42,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getTotal"),
"Chain should resolve T→Order and show 'getTotal', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getStatus"),
"Chain should resolve T→Order and show 'getStatus', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_nullable_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_nullable.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class Container {\n",
" /** @return ?T */\n",
" public function maybeGet() {}\n",
"}\n",
"\n",
"class Widget {\n",
" public function render(): string {}\n",
"}\n",
"\n",
"/**\n",
" * @extends Container<Widget>\n",
" */\n",
"class WidgetContainer extends Container {\n",
"}\n",
"\n",
"function test() {\n",
" $c = new WidgetContainer();\n",
" $c->maybeGet()->\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: 21,
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");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"render"),
"Should resolve ?T to ?Widget and show 'render', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_property_type_substitution() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_property.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class Wrapper {\n",
" /** @var T */\n",
" public $value;\n",
"}\n",
"\n",
"class Config {\n",
" public function get(string $key): string {}\n",
"}\n",
"\n",
"/**\n",
" * @extends Wrapper<Config>\n",
" */\n",
"class ConfigWrapper extends Wrapper {\n",
" function test() {\n",
" $this->value->\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: 18,
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");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"get"),
"Should resolve property type T→Config and show 'get', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_extends_cross_file_psr4() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#;
let parent_php = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class GenericCollection {\n",
" /** @return TValue */\n",
" public function first() {}\n",
" /** @return TValue|null */\n",
" public function last() {}\n",
" /** @return array<TKey, TValue> */\n",
" public function all() {}\n",
"}\n",
);
let item_php = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"class Item {\n",
" public function getName(): string {}\n",
" public function getPrice(): float {}\n",
"}\n",
);
let child_php = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"use App\\GenericCollection;\n",
"\n",
"/**\n",
" * @extends GenericCollection<int, Item>\n",
" */\n",
"class ItemCollection extends GenericCollection {\n",
" public function filterExpensive(): self {}\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer_json,
&[
("src/GenericCollection.php", parent_php),
("src/Item.php", item_php),
("src/ItemCollection.php", child_php),
],
);
let usage_text = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"use App\\ItemCollection;\n",
"\n",
"function test() {\n",
" $items = new ItemCollection();\n",
" $items->first()->\n",
"}\n",
);
let uri = Url::parse("file:///test_usage.php").unwrap();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: usage_text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
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");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Cross-file: should resolve TValue→Item and show 'getName', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getPrice"),
"Cross-file: should resolve TValue→Item and show 'getPrice', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_non_template_return_types_unchanged() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_non_template.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class BaseList {\n",
" /** @return T */\n",
" public function first() {}\n",
" /** @return self */\n",
" public function filter(): self {}\n",
" /** @return int */\n",
" public function count(): int {}\n",
"}\n",
"\n",
"class Task {\n",
" public function run(): void {}\n",
"}\n",
"\n",
"/**\n",
" * @extends BaseList<Task>\n",
" */\n",
"class TaskList extends BaseList {\n",
"}\n",
"\n",
"function test() {\n",
" $list = new TaskList();\n",
" $list->\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: 25,
character: 11,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"first"),
"Should include 'first', got: {:?}",
method_names
);
assert!(
method_names.contains(&"filter"),
"Should include 'filter' (returns self, not a template), got: {:?}",
method_names
);
assert!(
method_names.contains(&"count"),
"Should include 'count' (returns int, not a template), got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_chained_inheritance() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_chained.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template U\n",
" */\n",
"class GrandParent_ {\n",
" /** @return U */\n",
" public function getItem() {}\n",
"}\n",
"\n",
"/**\n",
" * @template T\n",
" * @extends GrandParent_<T>\n",
" */\n",
"class Parent_ extends GrandParent_ {\n",
" /** @return T */\n",
" public function findItem() {}\n",
"}\n",
"\n",
"class Car {\n",
" public function drive(): void {}\n",
" public function park(): void {}\n",
"}\n",
"\n",
"/**\n",
" * @extends Parent_<Car>\n",
" */\n",
"class CarStore extends Parent_ {\n",
"}\n",
"\n",
"function test() {\n",
" $store = new CarStore();\n",
" $store->findItem()->\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: uri.clone() },
position: Position {
line: 31,
character: 27,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"drive"),
"Should resolve T→Car on Parent_::findItem() and show 'drive', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_grandparent_method_resolves() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_grandparent.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template U\n",
" */\n",
"class BaseRepo {\n",
" /** @return U */\n",
" public function find() {}\n",
"}\n",
"\n",
"/**\n",
" * @template T\n",
" * @extends BaseRepo<T>\n",
" */\n",
"class CachingRepo extends BaseRepo {\n",
" public function clearCache(): void {}\n",
"}\n",
"\n",
"class Invoice {\n",
" public function getPdf(): string {}\n",
"}\n",
"\n",
"/**\n",
" * @extends CachingRepo<Invoice>\n",
" */\n",
"class InvoiceRepo extends CachingRepo {\n",
"}\n",
"\n",
"function test() {\n",
" $repo = new InvoiceRepo();\n",
" $repo->find()->\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: 29,
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");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getPdf"),
"Grandparent: should resolve U→T→Invoice and show 'getPdf', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_phpstan_extends_variant() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_phpstan.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class GenericStack {\n",
" /** @return T */\n",
" public function pop() {}\n",
"}\n",
"\n",
"class Message {\n",
" public function send(): void {}\n",
"}\n",
"\n",
"/**\n",
" * @phpstan-extends GenericStack<Message>\n",
" */\n",
"class MessageStack extends GenericStack {\n",
"}\n",
"\n",
"function test() {\n",
" $stack = new MessageStack();\n",
" $stack->pop()->\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: 21,
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");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"send"),
"@phpstan-extends should resolve T→Message, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_without_extends_annotation_no_crash() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_no_extends.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class GenericParent {\n",
" /** @return T */\n",
" public function get() {}\n",
" public function size(): int {}\n",
"}\n",
"\n",
"class PlainChild extends GenericParent {\n",
" function test() {\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: 12,
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"get"),
"Should inherit 'get' even without @extends, got: {:?}",
method_names
);
assert!(
method_names.contains(&"size"),
"Should inherit 'size' even without @extends, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[test]
fn test_extract_template_params_basic() {
use phpantom_lsp::docblock::extract_template_params;
let docblock = "/**\n * @template T\n */";
assert_eq!(extract_template_params(docblock), vec!["T"]);
}
#[test]
fn test_extract_template_params_multiple() {
use phpantom_lsp::docblock::extract_template_params;
let docblock = "/**\n * @template TKey\n * @template TValue\n */";
assert_eq!(extract_template_params(docblock), vec!["TKey", "TValue"]);
}
#[test]
fn test_extract_template_params_with_constraint() {
use phpantom_lsp::docblock::extract_template_params;
let docblock = "/**\n * @template TKey of array-key\n * @template TValue\n */";
assert_eq!(extract_template_params(docblock), vec!["TKey", "TValue"]);
}
#[test]
fn test_extract_template_params_covariant() {
use phpantom_lsp::docblock::extract_template_params;
let docblock = "/**\n * @template TKey\n * @template-covariant TValue\n */";
assert_eq!(extract_template_params(docblock), vec!["TKey", "TValue"]);
}
#[test]
fn test_extract_template_params_contravariant() {
use phpantom_lsp::docblock::extract_template_params;
let docblock = "/**\n * @template-contravariant TInput\n */";
assert_eq!(extract_template_params(docblock), vec!["TInput"]);
}
#[test]
fn test_extract_template_params_phpstan_prefix() {
use phpantom_lsp::docblock::extract_template_params;
let docblock = "/**\n * @phpstan-template T\n */";
assert_eq!(extract_template_params(docblock), vec!["T"]);
}
#[test]
fn test_extract_template_params_phpstan_covariant() {
use phpantom_lsp::docblock::extract_template_params;
let docblock = "/**\n * @phpstan-template-covariant TValue\n */";
assert_eq!(extract_template_params(docblock), vec!["TValue"]);
}
#[test]
fn test_extract_template_params_empty() {
use phpantom_lsp::docblock::extract_template_params;
let docblock = "/**\n * @return void\n */";
assert_eq!(extract_template_params(docblock), Vec::<String>::new());
}
#[test]
fn test_extract_generics_tag_extends_basic() {
use phpantom_lsp::docblock::extract_generics_tag;
use phpantom_lsp::php_type::PhpType;
let docblock = "/**\n * @extends Collection<int, Language>\n */";
let result = extract_generics_tag(docblock, "@extends");
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "Collection");
assert_eq!(
result[0].1,
vec![PhpType::parse("int"), PhpType::parse("Language")]
);
}
#[test]
fn test_extract_generics_tag_extends_single_param() {
use phpantom_lsp::docblock::extract_generics_tag;
use phpantom_lsp::php_type::PhpType;
let docblock = "/**\n * @extends Box<Apple>\n */";
let result = extract_generics_tag(docblock, "@extends");
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "Box");
assert_eq!(result[0].1, vec![PhpType::parse("Apple")]);
}
#[test]
fn test_extract_generics_tag_phpstan_extends() {
use phpantom_lsp::docblock::extract_generics_tag;
use phpantom_lsp::php_type::PhpType;
let docblock = "/**\n * @phpstan-extends Collection<int, User>\n */";
let result = extract_generics_tag(docblock, "@extends");
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "Collection");
assert_eq!(
result[0].1,
vec![PhpType::parse("int"), PhpType::parse("User")]
);
}
#[test]
fn test_extract_generics_tag_implements() {
use phpantom_lsp::docblock::extract_generics_tag;
use phpantom_lsp::php_type::PhpType;
let docblock = "/**\n * @implements ArrayAccess<string, User>\n */";
let result = extract_generics_tag(docblock, "@implements");
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "ArrayAccess");
assert_eq!(
result[0].1,
vec![PhpType::parse("string"), PhpType::parse("User")]
);
}
#[test]
fn test_extract_generics_tag_with_fqn() {
use phpantom_lsp::docblock::extract_generics_tag;
use phpantom_lsp::php_type::PhpType;
let docblock = "/**\n * @extends \\Illuminate\\Support\\Collection<int, \\App\\Model>\n */";
let result = extract_generics_tag(docblock, "@extends");
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "Illuminate\\Support\\Collection");
assert_eq!(
result[0].1,
vec![PhpType::parse("int"), PhpType::parse("\\App\\Model")]
);
}
#[test]
fn test_extract_generics_tag_nested_generic() {
use phpantom_lsp::docblock::extract_generics_tag;
use phpantom_lsp::php_type::PhpType;
let docblock = "/**\n * @extends Base<array<int, string>, User>\n */";
let result = extract_generics_tag(docblock, "@extends");
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "Base");
assert_eq!(
result[0].1,
vec![PhpType::parse("array<int, string>"), PhpType::parse("User")]
);
}
#[test]
fn test_extract_generics_tag_no_generics() {
use phpantom_lsp::docblock::extract_generics_tag;
let docblock = "/**\n * @return void\n */";
let result = extract_generics_tag(docblock, "@extends");
assert!(result.is_empty());
}
#[test]
fn test_extract_generics_tag_extends_without_angle_brackets() {
use phpantom_lsp::docblock::extract_generics_tag;
let docblock = "/**\n * @extends SomeClass\n */";
let result = extract_generics_tag(docblock, "@extends");
assert!(result.is_empty());
}
#[test]
fn test_extract_generics_tag_multiple_implements() {
use phpantom_lsp::docblock::extract_generics_tag;
use phpantom_lsp::php_type::PhpType;
let docblock = concat!(
"/**\n",
" * @implements ArrayAccess<int, User>\n",
" * @implements Countable\n",
" * @implements IteratorAggregate<int, User>\n",
" */",
);
let result = extract_generics_tag(docblock, "@implements");
assert_eq!(result.len(), 2);
assert_eq!(result[0].0, "ArrayAccess");
assert_eq!(
result[0].1,
vec![PhpType::parse("int"), PhpType::parse("User")]
);
assert_eq!(result[1].0, "IteratorAggregate");
assert_eq!(
result[1].1,
vec![PhpType::parse("int"), PhpType::parse("User")]
);
}
#[test]
fn test_synthesize_template_conditional_basic() {
use phpantom_lsp::docblock::synthesize_template_conditional;
use phpantom_lsp::php_type::PhpType;
let docblock = concat!(
"/**\n",
" * @template T\n",
" * @param class-string<T> $class\n",
" * @return T\n",
" */",
);
let template_params = vec!["T".to_string()];
let parsed = PhpType::parse("T");
let result = synthesize_template_conditional(docblock, &template_params, Some(&parsed), false);
assert!(
result.is_some(),
"Should synthesize a conditional for @template T with class-string<T>"
);
}
#[test]
fn test_synthesize_template_conditional_non_template_return() {
use phpantom_lsp::docblock::synthesize_template_conditional;
use phpantom_lsp::php_type::PhpType;
let docblock = concat!(
"/**\n",
" * @template T\n",
" * @param class-string<T> $class\n",
" * @return string\n",
" */",
);
let template_params = vec!["T".to_string()];
let parsed = PhpType::parse("string");
let result = synthesize_template_conditional(docblock, &template_params, Some(&parsed), false);
assert!(
result.is_none(),
"Should NOT synthesize when return type is not a template param"
);
}
#[test]
fn test_synthesize_template_conditional_no_templates() {
use phpantom_lsp::docblock::synthesize_template_conditional;
use phpantom_lsp::php_type::PhpType;
let docblock = concat!(
"/**\n",
" * @param string $class\n",
" * @return string\n",
" */",
);
let template_params: Vec<String> = vec![];
let parsed = PhpType::parse("string");
let result = synthesize_template_conditional(docblock, &template_params, Some(&parsed), false);
assert!(
result.is_none(),
"Should NOT synthesize when there are no template params"
);
}
#[test]
fn test_synthesize_template_conditional_existing_conditional() {
use phpantom_lsp::docblock::synthesize_template_conditional;
use phpantom_lsp::php_type::PhpType;
let docblock = concat!(
"/**\n",
" * @template T\n",
" * @param class-string<T> $class\n",
" * @return T\n",
" */",
);
let template_params = vec!["T".to_string()];
let parsed = PhpType::parse("T");
let result = synthesize_template_conditional(docblock, &template_params, Some(&parsed), true);
assert!(
result.is_none(),
"Should NOT synthesize when has_existing_conditional is true"
);
}
#[test]
fn test_synthesize_template_conditional_nullable_return() {
use phpantom_lsp::docblock::synthesize_template_conditional;
use phpantom_lsp::php_type::PhpType;
let docblock = concat!(
"/**\n",
" * @template T\n",
" * @param class-string<T> $class\n",
" * @return ?T\n",
" */",
);
let template_params = vec!["T".to_string()];
let parsed = PhpType::parse("?T");
let result = synthesize_template_conditional(docblock, &template_params, Some(&parsed), false);
assert!(
result.is_some(),
"Should synthesize for nullable return type ?T"
);
}
#[test]
fn test_synthesize_template_conditional_no_class_string_param() {
use phpantom_lsp::docblock::synthesize_template_conditional;
use phpantom_lsp::php_type::PhpType;
let docblock = concat!(
"/**\n",
" * @template T\n",
" * @param string $class\n",
" * @return T\n",
" */",
);
let template_params = vec!["T".to_string()];
let parsed = PhpType::parse("T");
let result = synthesize_template_conditional(docblock, &template_params, Some(&parsed), false);
assert!(
result.is_none(),
"Should NOT synthesize when no @param has class-string<T>"
);
}
#[test]
fn test_synthesize_template_conditional_nullable_class_string() {
use phpantom_lsp::docblock::synthesize_template_conditional;
use phpantom_lsp::php_type::PhpType;
let docblock = concat!(
"/**\n",
" * @template T\n",
" * @param ?class-string<T> $class\n",
" * @return T\n",
" */",
);
let template_params = vec!["T".to_string()];
let parsed = PhpType::parse("T");
let result = synthesize_template_conditional(docblock, &template_params, Some(&parsed), false);
assert!(
result.is_some(),
"Should synthesize for nullable class-string param ?class-string<T>"
);
}
#[test]
fn test_synthesize_template_conditional_class_string_null_union() {
use phpantom_lsp::docblock::synthesize_template_conditional;
use phpantom_lsp::php_type::PhpType;
let docblock = concat!(
"/**\n",
" * @template T\n",
" * @param class-string<T>|null $class\n",
" * @return T\n",
" */",
);
let template_params = vec!["T".to_string()];
let parsed = PhpType::parse("T");
let result = synthesize_template_conditional(docblock, &template_params, Some(&parsed), false);
assert!(
result.is_some(),
"Should synthesize for class-string<T>|null union param"
);
}
#[tokio::test]
async fn test_method_template_assignment_resolves_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_template_assign.php").unwrap();
let text = concat!(
"<?php\n", "class User {\n", " public function getName(): string {}\n", " public function getEmail(): string {}\n", "}\n", "\n", "class Repository {\n", " /**\n", " * @template T\n", " * @param class-string<T> $class\n", " * @return T\n", " */\n", " public function find(string $class): object {}\n", "}\n", "\n", "function test() {\n", " $repo = new Repository();\n", " $user = $repo->find(User::class);\n", " $user->\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: 18,
character: 11,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Should resolve T to User and show 'getName', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getEmail"),
"Should resolve T to User and show 'getEmail', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_inline_chain_resolves() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_template_chain.php").unwrap();
let text = concat!(
"<?php\n", "class Product {\n", " public function getPrice(): float {}\n", " public function getTitle(): string {}\n", "}\n", "\n", "class EntityManager {\n", " /**\n", " * @template T\n", " * @param class-string<T> $entityClass\n", " * @return T\n", " */\n", " public function find(string $entityClass): object {}\n", "}\n", "\n", "function test(EntityManager $em) {\n", " $em->find(Product::class)->\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: 16,
character: 35,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getPrice"),
"Should resolve T to Product and show 'getPrice', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getTitle"),
"Should resolve T to Product and show 'getTitle', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_static_method_resolves() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_template_static.php").unwrap();
let text = concat!(
"<?php\n", "class Order {\n", " public function getTotal(): float {}\n", " public function getStatus(): string {}\n", "}\n", "\n", "class Repository {\n", " /**\n", " * @template T\n", " * @param class-string<T> $class\n", " * @return T\n", " */\n", " public static function find(string $class): object {}\n", "}\n", "\n", "function test() {\n", " Repository::find(Order::class)->\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: 16,
character: 39,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getTotal"),
"Should resolve T to Order and show 'getTotal', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getStatus"),
"Should resolve T to Order and show 'getStatus', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_function_template_resolves_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///function_template.php").unwrap();
let text = concat!(
"<?php\n", "class Config {\n", " public function get(string $key): mixed {}\n", " public function set(string $key, mixed $val): void {}\n", "}\n", "\n", "/**\n", " * @template T\n", " * @param class-string<T> $class\n", " * @return T\n", " */\n", "function resolve(string $class): object {}\n", "\n", "function test() {\n", " resolve(Config::class)->\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: 33,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"get"),
"Should resolve T to Config and show 'get', got: {:?}",
method_names
);
assert!(
method_names.contains(&"set"),
"Should resolve T to Config and show 'set', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_function_template_assignment_resolves() {
let backend = create_test_backend();
let uri = Url::parse("file:///function_template_assign.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", "\n", "/**\n", " * @template T\n", " * @param class-string<T> $abstract\n", " * @return T\n", " */\n", "function resolve(string $abstract): object {}\n", "\n", "function test() {\n", " $logger = resolve(Logger::class);\n", " $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: 13,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"info"),
"Should resolve T to Logger and show 'info', got: {:?}",
method_names
);
assert!(
method_names.contains(&"error"),
"Should resolve T to Logger and show 'error', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_this_context_resolves() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_template_this.php").unwrap();
let text = concat!(
"<?php\n", "class Address {\n", " public function getCity(): string {}\n", " public function getZip(): string {}\n", "}\n", "\n", "class Container {\n", " /**\n", " * @template T\n", " * @param class-string<T> $id\n", " * @return T\n", " */\n", " public function get(string $id): object {}\n", "\n", " public function test() {\n", " $this->get(Address::class)->\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: 40,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getCity"),
"Should resolve T to Address and show 'getCity', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getZip"),
"Should resolve T to Address and show 'getZip', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_does_not_override_existing_conditional() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_template_existing.php").unwrap();
let text = concat!(
"<?php\n", "class Session {\n", " public function getId(): string {}\n", "}\n", "\n", "class App {\n", " /**\n", " * @template TClass\n", " * @param class-string<TClass>|null $abstract\n", " * @return ($abstract is class-string<TClass> ? TClass : App)\n", " */\n", " public function make(?string $abstract = null): mixed {}\n", "}\n", "\n", "function test(App $app) {\n", " $app->make(Session::class)->\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: 35,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getId"),
"Explicit conditional should still resolve Session, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_phpstan_template_resolves() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_phpstan_template.php").unwrap();
let text = concat!(
"<?php\n", "class Invoice {\n", " public function getAmount(): float {}\n", "}\n", "\n", "class Finder {\n", " /**\n", " * @phpstan-template T\n", " * @param class-string<T> $type\n", " * @return T\n", " */\n", " public function findOne(string $type): object {}\n", "}\n", "\n", "function test(Finder $f) {\n", " $f->findOne(Invoice::class)->\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: 35,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getAmount"),
"@phpstan-template should resolve T to Invoice, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_cross_file_resolves() {
let (backend, _dir) = create_psr4_workspace(
r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#,
&[(
"src/Payment.php",
"<?php\nnamespace App;\nclass Payment {\n public function charge(): void {}\n public function refund(): void {}\n}\n",
)],
);
let uri = Url::parse("file:///method_template_cross.php").unwrap();
let text = concat!(
"<?php\n",
"use App\\Payment;\n",
"\n",
"class ServiceLocator {\n",
" /**\n",
" * @template T\n",
" * @param class-string<T> $id\n",
" * @return T\n",
" */\n",
" public function get(string $id): object {}\n",
"}\n",
"\n",
"function test(ServiceLocator $sl) {\n",
" $sl->get(Payment::class)->\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: 33,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"charge"),
"Should resolve T to Payment cross-file and show 'charge', got: {:?}",
method_names
);
assert!(
method_names.contains(&"refund"),
"Should resolve T to Payment cross-file and show 'refund', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_different_param_name() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_template_param_name.php").unwrap();
let text = concat!(
"<?php\n", "class Customer {\n", " public function getLoyaltyPoints(): int {}\n", "}\n", "\n", "class ORM {\n", " /**\n", " * @template TEntity\n", " * @param class-string<TEntity> $entityClass\n", " * @return TEntity\n", " */\n", " public function find(string $entityClass): object {}\n", "}\n", "\n", "function test(ORM $orm) {\n", " $orm->find(Customer::class)->\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: 35,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getLoyaltyPoints"),
"Should resolve TEntity to Customer and show 'getLoyaltyPoints', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_property_type_preserves_context() {
let backend = create_test_backend();
let uri = Url::parse("file:///generic_property_context.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first() {}\n",
" /** @return TValue[] */\n",
" public function all() {}\n",
"}\n",
"\n",
"class User {\n",
" public function getName(): string {}\n",
" public function getEmail(): string {}\n",
"}\n",
"\n",
"class UserRepository {\n",
" /** @var Collection<int, User> */\n",
" public $users;\n",
"\n",
" function test() {\n",
" $this->users->first()->\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: 22,
character: 37,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Should resolve Collection<int, User>::first() → User and show 'getName', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getEmail"),
"Should resolve Collection<int, User>::first() → User and show 'getEmail', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_return_type_preserves_context() {
let backend = create_test_backend();
let uri = Url::parse("file:///generic_return_context.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first() {}\n",
"}\n",
"\n",
"class Product {\n",
" public function getPrice(): float {}\n",
" public function getSku(): string {}\n",
"}\n",
"\n",
"class Catalog {\n",
" /** @return Collection<int, Product> */\n",
" public function getProducts() {}\n",
"\n",
" function test() {\n",
" $this->getProducts()->first()->\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: 20,
character: 45,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getPrice"),
"Should resolve Collection<int, Product>::first() → Product and show 'getPrice', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getSku"),
"Should resolve Collection<int, Product>::first() → Product and show 'getSku', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_context_through_variable_assignment() {
let backend = create_test_backend();
let uri = Url::parse("file:///generic_var_assign.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class Box {\n",
" /** @return T */\n",
" public function unwrap() {}\n",
"}\n",
"\n",
"class Gift {\n",
" public function open(): string {}\n",
"}\n",
"\n",
"class Store {\n",
" /** @return Box<Gift> */\n",
" public function getGiftBox() {}\n",
"\n",
" function test() {\n",
" $box = $this->getGiftBox();\n",
" $box->unwrap()->\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: 19,
character: 28,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"open"),
"Should resolve Box<Gift>::unwrap() → Gift and show 'open', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_property_single_template_param() {
let backend = create_test_backend();
let uri = Url::parse("file:///generic_prop_single.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class Container {\n",
" /** @return T */\n",
" public function get() {}\n",
"}\n",
"\n",
"class Config {\n",
" public function has(string $key): bool {}\n",
" public function all(): array {}\n",
"}\n",
"\n",
"class App {\n",
" /** @var Container<Config> */\n",
" public $config;\n",
"\n",
" function test() {\n",
" $this->config->get()->\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: 19,
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 return results");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"has"),
"Should resolve Container<Config>::get() → Config and show 'has', got: {:?}",
method_names
);
assert!(
method_names.contains(&"all"),
"Should resolve Container<Config>::get() → Config and show 'all', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_property_context_cross_file_psr4() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#;
let collection_php = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first() {}\n",
" /** @return static */\n",
" public function filter(callable $fn) {}\n",
"}\n",
);
let user_php = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"class User {\n",
" public function getName(): string {}\n",
" public function getAge(): int {}\n",
"}\n",
);
let service_php = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"class UserService {\n",
" /** @var Collection<int, User> */\n",
" public $users;\n",
"\n",
" function test() {\n",
" $this->users->first()->\n",
" }\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer_json,
&[
("src/Collection.php", collection_php),
("src/User.php", user_php),
("src/UserService.php", service_php),
],
);
let uri = Url::parse("file:///src/UserService.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: 8,
character: 37,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Cross-file: should resolve Collection<int, User>::first() → User, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getAge"),
"Cross-file: should resolve Collection<int, User>::first() → User, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_nullable_union_preserves_context() {
let backend = create_test_backend();
let uri = Url::parse("file:///generic_nullable_union.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class Optional {\n",
" /** @return T */\n",
" public function get() {}\n",
"}\n",
"\n",
"class Session {\n",
" public function getId(): string {}\n",
"}\n",
"\n",
"class Handler {\n",
" /** @var Optional<Session>|null */\n",
" public $session;\n",
"\n",
" function test() {\n",
" $this->session->get()->\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: 18,
character: 35,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getId"),
"Should resolve Optional<Session>::get() → Session through nullable union, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_non_generic_return_type_still_works() {
let backend = create_test_backend();
let uri = Url::parse("file:///non_generic_still_works.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",
"\n",
"class Service {\n",
" /** @return Logger */\n",
" public function getLogger() {}\n",
"\n",
" function test() {\n",
" $this->getLogger()->\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: 32,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"info"),
"Plain @return Logger should still work, got: {:?}",
method_names
);
assert!(
method_names.contains(&"error"),
"Plain @return Logger should still work, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_extends_and_inline_var_coexist() {
let backend = create_test_backend();
let uri = Url::parse("file:///generic_extends_and_var.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class BaseRepo {\n",
" /** @return T */\n",
" public function find(int $id) {}\n",
"}\n",
"\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first() {}\n",
"}\n",
"\n",
"class Order {\n",
" public function getTotal(): float {}\n",
"}\n",
"\n",
"class Item {\n",
" public function getQuantity(): int {}\n",
"}\n",
"\n",
"/**\n",
" * @extends BaseRepo<Order>\n",
" */\n",
"class OrderRepo extends BaseRepo {\n",
" /** @var Collection<int, Item> */\n",
" public $lineItems;\n",
"\n",
" function testFind() {\n",
" $this->find(1)->\n",
" }\n",
"\n",
" function testLineItems() {\n",
" $this->lineItems->first()->\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: uri.clone() },
position: Position {
line: 34,
character: 29,
},
},
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 find()"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getTotal"),
"@extends BaseRepo<Order>: find() should return Order, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
let completion_params2 = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 38,
character: 42,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result2 = backend.completion(completion_params2).await.unwrap();
assert!(
result2.is_some(),
"Completion should return results for lineItems->first()"
);
match result2.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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getQuantity"),
"@var Collection<int, Item>: first() should return Item, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_property_own_methods_still_visible() {
let backend = create_test_backend();
let uri = Url::parse("file:///generic_prop_own_methods.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first() {}\n",
" public function count(): int {}\n",
" public function isEmpty(): bool {}\n",
"}\n",
"\n",
"class Tag {\n",
" public function getLabel(): string {}\n",
"}\n",
"\n",
"class Article {\n",
" /** @var Collection<int, Tag> */\n",
" public $tags;\n",
"\n",
" function test() {\n",
" $this->tags->\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: 21,
character: 21,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"first"),
"Collection's own 'first' method should be visible, got: {:?}",
method_names
);
assert!(
method_names.contains(&"count"),
"Collection's own 'count' method should be visible, got: {:?}",
method_names
);
assert!(
method_names.contains(&"isEmpty"),
"Collection's own 'isEmpty' method should be visible, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_property_assignment_to_variable() {
let backend = create_test_backend();
let uri = Url::parse("file:///generic_prop_assign.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class Box {\n",
" /** @return T */\n",
" public function unwrap() {}\n",
"}\n",
"\n",
"class Gift {\n",
" public function open(): string {}\n",
" public function getTag(): string {}\n",
"}\n",
"\n",
"class GiftShop {\n",
" /** @var Box<Gift> */\n",
" public $giftBox;\n",
"\n",
" function test() {\n",
" $box = $this->giftBox;\n",
" $box->unwrap()->\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: 20,
character: 28,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"open"),
"Should resolve $box = $this->giftBox (Box<Gift>), unwrap() → Gift, show 'open', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getTag"),
"Should resolve $box = $this->giftBox (Box<Gift>), unwrap() → Gift, show 'getTag', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_trait_use_generic_resolves_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_use_generic_basic.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template TFactory\n",
" */\n",
"trait HasFactory {\n",
" /** @return TFactory */\n",
" public static function factory() {}\n",
"}\n",
"\n",
"class UserFactory {\n",
" public function create(): void {}\n",
" public function count(int $n): void {}\n",
"}\n",
"\n",
"/**\n",
" * @use HasFactory<UserFactory>\n",
" */\n",
"class User {\n",
" use HasFactory;\n",
"}\n",
"\n",
"function test() {\n",
" User::factory()->\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: 22,
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");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"create"),
"Should resolve TFactory to UserFactory and show 'create', got: {:?}",
method_names
);
assert!(
method_names.contains(&"count"),
"Should resolve TFactory to UserFactory and show 'count', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_trait_use_generic_two_params() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_use_generic_two_params.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"trait Indexable {\n",
" /** @return TValue */\n",
" public function get() {}\n",
" /** @return TKey */\n",
" public function key() {}\n",
"}\n",
"\n",
"class User {\n",
" public function getName(): string {}\n",
"}\n",
"\n",
"/**\n",
" * @use Indexable<int, User>\n",
" */\n",
"class UserList {\n",
" use Indexable;\n",
"}\n",
"\n",
"function test() {\n",
" $list = new UserList();\n",
" $list->get()->\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: 25,
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_some(), "Completion should return results");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Should resolve TValue to User and show User's 'getName', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_trait_use_generic_property_substitution() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_use_generic_property.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template TModel\n",
" */\n",
"trait HasRelation {\n",
" /** @var TModel */\n",
" public $related;\n",
"}\n",
"\n",
"class Address {\n",
" public function getCity(): string {}\n",
" public function getZip(): string {}\n",
"}\n",
"\n",
"/**\n",
" * @use HasRelation<Address>\n",
" */\n",
"class User {\n",
" use HasRelation;\n",
"}\n",
"\n",
"function test() {\n",
" $user = new User();\n",
" $user->related->\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: 23,
character: 21,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getCity"),
"Should resolve TModel to Address and show 'getCity', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getZip"),
"Should resolve TModel to Address and show 'getZip', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_trait_phpstan_use_variant() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_phpstan_use.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"trait Wrapper {\n",
" /** @return T */\n",
" public function unwrap() {}\n",
"}\n",
"\n",
"class Gift {\n",
" public function open(): void {}\n",
"}\n",
"\n",
"/**\n",
" * @phpstan-use Wrapper<Gift>\n",
" */\n",
"class GiftBox {\n",
" use Wrapper;\n",
"}\n",
"\n",
"function test() {\n",
" $box = new GiftBox();\n",
" $box->unwrap()->\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: 22,
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");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"open"),
"Should resolve T to Gift via @phpstan-use and show 'open', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_trait_use_generic_class_own_wins() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_use_generic_precedence.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"trait Getter {\n",
" /** @return T */\n",
" public function get() {}\n",
" /** @return T */\n",
" public function first() {}\n",
"}\n",
"\n",
"class Apple {\n",
" public function bite(): void {}\n",
"}\n",
"\n",
"class Orange {\n",
" public function squeeze(): void {}\n",
"}\n",
"\n",
"/**\n",
" * @use Getter<Apple>\n",
" */\n",
"class FruitBowl {\n",
" use Getter;\n",
" /** @return Orange */\n",
" public function get() {}\n",
"}\n",
"\n",
"function test() {\n",
" $bowl = new FruitBowl();\n",
" $bowl->get()->\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: 30,
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_some(), "Completion should return results");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"squeeze"),
"Class own 'get' should override trait's, returning Orange with 'squeeze', got: {:?}",
method_names
);
assert!(
!method_names.contains(&"bite"),
"Apple's 'bite' should NOT appear since class own 'get' returns Orange, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_trait_use_generic_cross_file_psr4() {
let composer_json = r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#;
let (backend, _dir) = create_psr4_workspace(
composer_json,
&[
(
"src/Concerns/HasFactory.php",
concat!(
"<?php\n",
"namespace App\\Concerns;\n",
"\n",
"/**\n",
" * @template TFactory\n",
" */\n",
"trait HasFactory {\n",
" /** @return TFactory */\n",
" public static function factory() {}\n",
"}\n",
),
),
(
"src/Factories/UserFactory.php",
concat!(
"<?php\n",
"namespace App\\Factories;\n",
"\n",
"class UserFactory {\n",
" public function create(): void {}\n",
" public function make(): void {}\n",
"}\n",
),
),
(
"src/Models/User.php",
concat!(
"<?php\n",
"namespace App\\Models;\n",
"\n",
"use App\\Concerns\\HasFactory;\n",
"use App\\Factories\\UserFactory;\n",
"\n",
"/**\n",
" * @use HasFactory<UserFactory>\n",
" */\n",
"class User {\n",
" use HasFactory;\n",
"}\n",
),
),
],
);
let uri = Url::parse("file:///test_trait_cross_file.php").unwrap();
let text = concat!(
"<?php\n",
"use App\\Models\\User;\n",
"\n",
"function test() {\n",
" User::factory()->\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: 4,
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");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"create"),
"Should resolve TFactory to UserFactory across files and show 'create', got: {:?}",
method_names
);
assert!(
method_names.contains(&"make"),
"Should resolve TFactory to UserFactory across files and show 'make', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_trait_use_without_generics_no_crash() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_no_use_generics.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"trait Wrapper {\n",
" /** @return T */\n",
" public function unwrap() {}\n",
" public function isEmpty(): bool {}\n",
"}\n",
"\n",
"class Box {\n",
" use Wrapper;\n",
"}\n",
"\n",
"function test() {\n",
" $box = new Box();\n",
" $box->\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: 16,
character: 10,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"unwrap"),
"Trait method 'unwrap' should be visible even without @use generics, got: {:?}",
method_names
);
assert!(
method_names.contains(&"isEmpty"),
"Trait method 'isEmpty' should be visible, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_trait_use_generic_this_context() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_use_generic_this.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template TFactory\n",
" */\n",
"trait HasFactory {\n",
" /** @return TFactory */\n",
" public function getFactory() {}\n",
"}\n",
"\n",
"class UserFactory {\n",
" public function create(): void {}\n",
"}\n",
"\n",
"/**\n",
" * @use HasFactory<UserFactory>\n",
" */\n",
"class User {\n",
" use HasFactory;\n",
"\n",
" public function test() {\n",
" $this->getFactory()->\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: 20,
character: 33,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"create"),
"Should resolve TFactory to UserFactory via $this-> and show 'create', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[test]
fn test_extract_generics_tag_use_basic() {
use phpantom_lsp::docblock::extract_generics_tag;
use phpantom_lsp::php_type::PhpType;
let docblock = "/**\n * @use HasFactory<UserFactory>\n */";
let result = extract_generics_tag(docblock, "@use");
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "HasFactory");
assert_eq!(result[0].1, vec![PhpType::parse("UserFactory")]);
}
#[test]
fn test_extract_generics_tag_phpstan_use() {
use phpantom_lsp::docblock::extract_generics_tag;
use phpantom_lsp::php_type::PhpType;
let docblock = "/**\n * @phpstan-use HasFactory<UserFactory>\n */";
let result = extract_generics_tag(docblock, "@use");
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "HasFactory");
assert_eq!(result[0].1, vec![PhpType::parse("UserFactory")]);
}
#[test]
fn test_extract_generics_tag_use_multiple_params() {
use phpantom_lsp::docblock::extract_generics_tag;
use phpantom_lsp::php_type::PhpType;
let docblock = "/**\n * @use Indexable<int, User>\n */";
let result = extract_generics_tag(docblock, "@use");
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "Indexable");
assert_eq!(
result[0].1,
vec![PhpType::parse("int"), PhpType::parse("User")]
);
}
#[test]
fn test_extract_generics_tag_use_fqn() {
use phpantom_lsp::docblock::extract_generics_tag;
use phpantom_lsp::php_type::PhpType;
let docblock = "/**\n * @use \\App\\Concerns\\HasFactory<\\App\\Factories\\UserFactory>\n */";
let result = extract_generics_tag(docblock, "@use");
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "App\\Concerns\\HasFactory");
assert_eq!(
result[0].1,
vec![PhpType::parse("\\App\\Factories\\UserFactory")]
);
}
#[test]
fn test_extract_template_param_bindings_basic() {
use phpantom_lsp::docblock::extract_template_param_bindings;
let docblock = concat!(
"/**\n",
" * @template T\n",
" * @param T $model\n",
" * @return Collection<T>\n",
" */",
);
let tpl_params = vec!["T".to_string()];
let result = extract_template_param_bindings(docblock, &tpl_params);
assert_eq!(result, vec![("T".to_string(), "$model".to_string())]);
}
#[test]
fn test_extract_template_param_bindings_nullable() {
use phpantom_lsp::docblock::extract_template_param_bindings;
let docblock = concat!(
"/**\n",
" * @template T\n",
" * @param ?T $model\n",
" * @return T\n",
" */",
);
let tpl_params = vec!["T".to_string()];
let result = extract_template_param_bindings(docblock, &tpl_params);
assert_eq!(result, vec![("T".to_string(), "$model".to_string())]);
}
#[test]
fn test_extract_template_param_bindings_union_null() {
use phpantom_lsp::docblock::extract_template_param_bindings;
let docblock = concat!(
"/**\n",
" * @template T\n",
" * @param T|null $model\n",
" * @return T\n",
" */",
);
let tpl_params = vec!["T".to_string()];
let result = extract_template_param_bindings(docblock, &tpl_params);
assert_eq!(result, vec![("T".to_string(), "$model".to_string())]);
}
#[test]
fn test_extract_template_param_bindings_multiple() {
use phpantom_lsp::docblock::extract_template_param_bindings;
let docblock = concat!(
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" * @param TKey $key\n",
" * @param TValue $value\n",
" * @return Pair<TKey, TValue>\n",
" */",
);
let tpl_params = vec!["TKey".to_string(), "TValue".to_string()];
let result = extract_template_param_bindings(docblock, &tpl_params);
assert_eq!(
result,
vec![
("TKey".to_string(), "$key".to_string()),
("TValue".to_string(), "$value".to_string()),
]
);
}
#[test]
fn test_extract_template_param_bindings_non_template_ignored() {
use phpantom_lsp::docblock::extract_template_param_bindings;
let docblock = concat!(
"/**\n",
" * @template T\n",
" * @param string $name\n",
" * @param T $model\n",
" * @return T\n",
" */",
);
let tpl_params = vec!["T".to_string()];
let result = extract_template_param_bindings(docblock, &tpl_params);
assert_eq!(result, vec![("T".to_string(), "$model".to_string())]);
}
#[test]
fn test_extract_template_param_bindings_empty_templates() {
use phpantom_lsp::docblock::extract_template_param_bindings;
let docblock = concat!("/**\n", " * @param string $name\n", " */",);
let tpl_params: Vec<String> = vec![];
let result = extract_template_param_bindings(docblock, &tpl_params);
assert!(result.is_empty());
}
#[test]
fn test_extract_template_param_bindings_multi_param_generic() {
use phpantom_lsp::docblock::extract_template_param_bindings;
let docblock = concat!(
"/**\n",
" * @template TKey of array-key\n",
" * @template TValue\n",
" * @param array<TKey, TValue> $value\n",
" * @return Collection<TKey, TValue>\n",
" */",
);
let tpl_params = vec!["TKey".to_string(), "TValue".to_string()];
let result = extract_template_param_bindings(docblock, &tpl_params);
assert_eq!(
result,
vec![
("TKey".to_string(), "$value".to_string()),
("TValue".to_string(), "$value".to_string()),
]
);
}
#[tokio::test]
async fn test_function_template_collect_inline_chain() {
let backend = create_test_backend();
let uri = Url::parse("file:///function_template_collect.php").unwrap();
let text = concat!(
"<?php\n", "class User {\n", " public function getName(): string {}\n", "}\n", "\n", "/**\n", " * @template TKey of array-key\n", " * @template TValue\n", " */\n", "class Collection {\n", " /** @return TValue */\n", " public function first(): mixed {}\n", " public function count(): int {}\n", "}\n", "\n", "/**\n", " * @template TKey of array-key\n", " * @template TValue\n", " * @param array<TKey, TValue> $value\n", " * @return Collection<TKey, TValue>\n", " */\n", "function collect(array $value = []): Collection {}\n", "\n", "function test() {\n", " /** @var User[] $users */\n", " $users = [];\n", " collect($users)->\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: 26,
character: 21,
},
},
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 collect($users)->"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"first"),
"Should show Collection's 'first' method on collect($users)->, got: {:?}",
method_names
);
assert!(
method_names.contains(&"count"),
"Should show Collection's 'count' method on collect($users)->, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_function_template_collect_assignment() {
let backend = create_test_backend();
let uri = Url::parse("file:///function_template_collect_assign.php").unwrap();
let text = concat!(
"<?php\n", "class User {\n", " public function getName(): string {}\n", "}\n", "\n", "/**\n", " * @template TKey of array-key\n", " * @template TValue\n", " */\n", "class Collection {\n", " /** @return TValue */\n", " public function first(): mixed {}\n", " public function count(): int {}\n", "}\n", "\n", "/**\n", " * @template TKey of array-key\n", " * @template TValue\n", " * @param array<TKey, TValue> $value\n", " * @return Collection<TKey, TValue>\n", " */\n", "function collect(array $value = []): Collection {}\n", "\n", "function test() {\n", " /** @var User[] $users */\n", " $users = [];\n", " $collection = collect($users);\n", " $collection->\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: 27,
character: 17,
},
},
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 $collection-> after collect($users)"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"first"),
"Should show Collection's 'first' method on $collection->, got: {:?}",
method_names
);
assert!(
method_names.contains(&"count"),
"Should show Collection's 'count' method on $collection->, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_function_template_collect_deep_chain() {
let backend = create_test_backend();
let uri = Url::parse("file:///function_template_collect_deep.php").unwrap();
let text = concat!(
"<?php\n", "class User {\n", " public function getName(): string {}\n", " public function getEmail(): string {}\n", "}\n", "\n", "/**\n", " * @template TKey of array-key\n", " * @template TValue\n", " */\n", "class Collection {\n", " /** @return TValue */\n", " public function first(): mixed {}\n", " public function count(): int {}\n", "}\n", "\n", "/**\n", " * @template TKey of array-key\n", " * @template TValue\n", " * @param array<TKey, TValue> $value\n", " * @return Collection<TKey, TValue>\n", " */\n", "function collect(array $value = []): Collection {}\n", "\n", "function test() {\n", " /** @var User[] $users */\n", " $users = [];\n", " collect($users)->first()->\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: 27,
character: 30,
},
},
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 collect($users)->first()->"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Should resolve TValue→User through collect() + first() chain, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getEmail"),
"Should resolve TValue→User through collect() + first() chain, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_function_template_collect_laravel_union_param() {
let backend = create_test_backend();
let uri = Url::parse("file:///function_template_collect_laravel.php").unwrap();
let text = concat!(
"<?php\n", "class User {\n", " public function getName(): string {}\n", " public function getEmail(): string {}\n", "}\n", "\n", "/**\n", " * @template TKey of array-key\n", " * @template TValue\n", " */\n", "class Collection {\n", " /** @return TValue */\n", " public function first(): mixed {}\n", " public function count(): int {}\n", "}\n", "\n", "/**\n", " * @template TKey of array-key\n", " * @template TValue\n", " * @param Arrayable<TKey, TValue>|iterable<TKey, TValue>|null $value\n", " * @return Collection<TKey, TValue>\n", " */\n", "function collect($value = []): Collection {}\n", "\n", "function test() {\n", " /** @var User[] $users */\n", " $users = [];\n", " collect($users)->first()->\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: 27,
character: 30,
},
},
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 collect($users)->first()-> with Laravel union @param"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Should resolve TValue→User through collect() with union @param + first(), got: {:?}",
method_names
);
assert!(
method_names.contains(&"getEmail"),
"Should resolve TValue→User through collect() with union @param + first(), got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_inline_chain() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_general_inline.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string {}\n",
" public function getEmail(): string {}\n",
"}\n",
"\n",
"/** @template TValue */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first(): mixed {}\n",
"}\n",
"\n",
"class Repository {\n",
" /**\n",
" * @template T of object\n",
" * @param T $model\n",
" * @return Collection<T>\n",
" */\n",
" public function wrap(object $model): Collection {}\n",
"}\n",
"\n",
"function test(Repository $repo, User $user) {\n",
" $repo->wrap($user)->first()->\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: 22,
character: 35,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Should resolve T→User via Collection<T>.first()→User, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getEmail"),
"Should resolve T→User, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_this_context() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_general_this.php").unwrap();
let text = concat!(
"<?php\n",
"class Product {\n",
" public function getPrice(): float {}\n",
" public function getTitle(): string {}\n",
"}\n",
"\n",
"/** @template TValue */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first(): mixed {}\n",
"}\n",
"\n",
"class Service {\n",
" /**\n",
" * @template T\n",
" * @param T $item\n",
" * @return Collection<T>\n",
" */\n",
" public function collect(mixed $item): Collection {}\n",
"\n",
" public function run(Product $product) {\n",
" $this->collect($product)->first()->\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: 21,
character: 46,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getPrice"),
"Should resolve T→Product, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getTitle"),
"Should resolve T→Product, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_assignment() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_general_assign.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string {}\n",
"}\n",
"\n",
"/** @template TValue */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first(): mixed {}\n",
"}\n",
"\n",
"class Repository {\n",
" /**\n",
" * @template T\n",
" * @param T $model\n",
" * @return Collection<T>\n",
" */\n",
" public function wrap(object $model): Collection {}\n",
"}\n",
"\n",
"function test(Repository $repo, User $user) {\n",
" $collection = $repo->wrap($user);\n",
" $collection->first()->\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: 22,
character: 26,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Should resolve T→User through assignment, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_static_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_general_static.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string {}\n",
"}\n",
"\n",
"/** @template TValue */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first(): mixed {}\n",
"}\n",
"\n",
"class Repository {\n",
" /**\n",
" * @template T\n",
" * @param T $model\n",
" * @return Collection<T>\n",
" */\n",
" public static function wrap(object $model): Collection {}\n",
"}\n",
"\n",
"function test(User $user) {\n",
" Repository::wrap($user)->first()->\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: 21,
character: 39,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Static method @template T + @param T should resolve T→User, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_direct_return() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_general_direct.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string {}\n",
"}\n",
"\n",
"class Util {\n",
" /**\n",
" * @template T\n",
" * @param T $item\n",
" * @return T\n",
" */\n",
" public function identity(mixed $item): mixed {}\n",
"}\n",
"\n",
"function test(Util $util, User $user) {\n",
" $util->identity($user)->\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: 29,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Direct @return T with @param T should resolve T→User, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_multiple_params() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_general_multi.php").unwrap();
let text = concat!(
"<?php\n",
"class Category {\n",
" public function getLabel(): string {}\n",
"}\n",
"\n",
"class Product {\n",
" public function getPrice(): float {}\n",
"}\n",
"\n",
"/** @template TValue */\n",
"class Wrapper {\n",
" /** @return TValue */\n",
" public function unwrap(): mixed {}\n",
"}\n",
"\n",
"class Factory {\n",
" /**\n",
" * @template TKey\n",
" * @template TValue\n",
" * @param TKey $key\n",
" * @param TValue $value\n",
" * @return Wrapper<TValue>\n",
" */\n",
" public function make(mixed $key, mixed $value): Wrapper {}\n",
"}\n",
"\n",
"function test(Factory $f, Category $cat, Product $prod) {\n",
" $f->make($cat, $prod)->unwrap()->\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: 27,
character: 38,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getPrice"),
"TValue should resolve to Product (second arg), got: {:?}",
method_names
);
assert!(
!method_names.contains(&"getLabel"),
"TKey (Category) should not leak into TValue resolution, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_does_not_break_class_string() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_general_regression.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string {}\n",
"}\n",
"\n",
"class Repository {\n",
" /**\n",
" * @template T\n",
" * @param class-string<T> $class\n",
" * @return T\n",
" */\n",
" public function find(string $class): object {}\n",
"}\n",
"\n",
"function test(Repository $repo) {\n",
" $repo->find(User::class)->\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: 30,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"class-string<T> path should still work, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_this_assignment() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_general_this_assign.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string {}\n",
"}\n",
"\n",
"/** @template TValue */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first(): mixed {}\n",
"}\n",
"\n",
"class Service {\n",
" /**\n",
" * @template T\n",
" * @param T $item\n",
" * @return Collection<T>\n",
" */\n",
" public function wrap(mixed $item): Collection {}\n",
"\n",
" public function run(User $user) {\n",
" $result = $this->wrap($user);\n",
" $result->first()->\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: 21,
character: 26,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"$this->wrap($user) assignment should resolve T→User, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_cross_file_psr4() {
let composer_json = r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#;
let user_php = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"class User {\n",
" public function getName(): string {}\n",
" public function getEmail(): string {}\n",
"}\n",
);
let coll_php = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"/** @template TValue */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first(): mixed {}\n",
" public function count(): int {}\n",
"}\n",
);
let repo_php = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"class Repository {\n",
" /**\n",
" * @template T\n",
" * @param T $model\n",
" * @return Collection<T>\n",
" */\n",
" public function wrap(object $model): Collection {}\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer_json,
&[
("src/User.php", user_php),
("src/Collection.php", coll_php),
("src/Repository.php", repo_php),
],
);
let test_text = concat!(
"<?php\n", "namespace App;\n", "\n", "use App\\Repository;\n", "use App\\User;\n", "\n", "class TestConsumer {\n", " public function handle(Repository $repo, User $user) {\n", " $repo->wrap($user)->\n", " }\n", "}\n", );
let uri = Url::parse("file:///test_cross_tpl.php").unwrap();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: test_text.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 8,
character: 29,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"first"),
"Cross-file @template T + @param T should resolve to Collection<User> showing 'first', got: {:?}",
method_names
);
assert!(
method_names.contains(&"count"),
"Cross-file should also show Collection's 'count', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_new_expression_arg() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_general_new_arg.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string {}\n",
"}\n",
"\n",
"class Util {\n",
" /**\n",
" * @template T\n",
" * @param T $item\n",
" * @return T\n",
" */\n",
" public function identity(mixed $item): mixed {}\n",
"}\n",
"\n",
"function test(Util $util) {\n",
" $util->identity(new User())->\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 return results");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"`new User()` arg should resolve T→User, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_phpstan_prefix() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_general_phpstan.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string {}\n",
"}\n",
"\n",
"class Util {\n",
" /**\n",
" * @phpstan-template T\n",
" * @param T $item\n",
" * @return T\n",
" */\n",
" public function identity(mixed $item): mixed {}\n",
"}\n",
"\n",
"function test(Util $util, User $user) {\n",
" $util->identity($user)->\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: 29,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"@phpstan-template variant should resolve T→User, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_non_template_unaffected() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_general_no_tpl.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string {}\n",
"}\n",
"\n",
"class Service {\n",
" public function getUser(): User {}\n",
"}\n",
"\n",
"function test(Service $svc) {\n",
" $svc->getUser()->\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: 10,
character: 21,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Non-template method should still work, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_this_property_arg() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_general_prop_arg.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string {}\n",
"}\n",
"\n",
"class Util {\n",
" /**\n",
" * @template T\n",
" * @param T $item\n",
" * @return T\n",
" */\n",
" public function identity(mixed $item): mixed {}\n",
"}\n",
"\n",
"class Controller {\n",
" private User $user;\n",
"\n",
" public function handle(Util $util) {\n",
" $util->identity($this->user)->\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: 18,
character: 39,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"$this->property arg should resolve T→User, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_deep_chain_new_arg() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_deep_chain.php").unwrap();
let text = concat!(
"<?php\n", "class Product {\n", " public function getPrice(): float {}\n", "}\n", "\n", "/** @template TValue */\n", "class TypedCollection {\n", " /** @return TValue */\n", " public function first(): mixed {}\n", "}\n", "\n", "class ObjectMapper {\n", " /**\n", " * @template T\n", " * @param T $item\n", " * @return TypedCollection<T>\n", " */\n", " public function wrap(object $item): TypedCollection {}\n", "}\n", "\n", "function test() {\n", " $mapper = new ObjectMapper();\n", " $mapper->wrap(new Product())->first()->\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: 22,
character: 47,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getPrice"),
"3-level chain: wrap(new Product())->first()-> should resolve to Product, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_general_deep_chain_namespaced() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_deep_chain_ns.php").unwrap();
let text = concat!(
"<?php\n", "namespace App;\n", "\n", "class Product {\n", " public function getPrice(): float {}\n", "}\n", "\n", "/** @template TValue */\n", "class TypedCollection {\n", " /** @return TValue */\n", " public function first(): mixed {}\n", "}\n", "\n", "class ObjectMapper {\n", " /**\n", " * @template T\n", " * @param T $item\n", " * @return TypedCollection<T>\n", " */\n", " public function wrap(object $item): TypedCollection {}\n", "}\n", "\n", "function test() {\n", " $mapper = new ObjectMapper();\n", " $mapper->wrap(new Product())->first()->\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: 24,
character: 47,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getPrice"),
"Namespaced 3-level chain: wrap(new Product())->first()-> should resolve to Product, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_match_class_string_forwarded_to_method_conditional_return() {
let backend = create_test_backend();
let uri = Url::parse("file:///match_class_string_method.php").unwrap();
let text = concat!(
"<?php\n", "class GetCreditnotesRequest {\n", " public function getCreditnotes(): array {}\n", "}\n", "\n", "class GetOrdersRequest {\n", " public function getOrders(): array {}\n", "}\n", "\n", "class Container {\n", " /**\n", " * @template T\n", " * @param class-string<T> $abstract\n", " * @return T\n", " */\n", " public function make(string $abstract): object {}\n", "}\n", "\n", "class App {\n", " public function run(string $typeName): void {\n", " $container = new Container();\n", " $requestType = match ($typeName) {\n", " 'creditnotes' => GetCreditnotesRequest::class,\n", " 'orders' => GetOrdersRequest::class,\n", " };\n", " $requestBody = $container->make($requestType);\n", " $requestBody->\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 params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 26,
character: 22,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(params).await.unwrap();
assert!(
result.is_some(),
"Completion should return results for $requestBody->"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getCreditnotes"),
"Should include getCreditnotes from GetCreditnotesRequest, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getOrders"),
"Should include getOrders from GetOrdersRequest, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_match_class_string_forwarded_to_function_conditional_return() {
let backend = create_test_backend();
let uri = Url::parse("file:///match_class_string_func.php").unwrap();
let text = concat!(
"<?php\n", "class Alpha {\n", " public function alphaMethod(): void {}\n", "}\n", "\n", "class Beta {\n", " public function betaMethod(): void {}\n", "}\n", "\n", "/**\n", " * @template T\n", " * @param class-string<T> $class\n", " * @return T\n", " */\n", "function resolve(string $class): object {}\n", "\n", "class Service {\n", " public function handle(int $type): void {\n", " $cls = match ($type) {\n", " 1 => Alpha::class,\n", " 2 => Beta::class,\n", " };\n", " $obj = resolve($cls);\n", " $obj->\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 params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 23,
character: 14,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(params).await.unwrap();
assert!(
result.is_some(),
"Completion should return results for $obj->"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"alphaMethod"),
"Should include alphaMethod from Alpha, got: {:?}",
method_names
);
assert!(
method_names.contains(&"betaMethod"),
"Should include betaMethod from Beta, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_simple_class_string_variable_forwarded_to_conditional_return() {
let backend = create_test_backend();
let uri = Url::parse("file:///simple_class_string_var.php").unwrap();
let text = concat!(
"<?php\n", "class User {\n", " public function getName(): string {}\n", "}\n", "\n", "class Repository {\n", " /**\n", " * @template T\n", " * @param class-string<T> $class\n", " * @return T\n", " */\n", " public function find(string $class): object {}\n", "}\n", "\n", "class Service {\n", " public function handle(): void {\n", " $repo = new Repository();\n", " $cls = User::class;\n", " $user = $repo->find($cls);\n", " $user->\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 params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 19,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(params).await.unwrap();
assert!(
result.is_some(),
"Completion should return results for $user->"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Should resolve $cls = User::class through variable and show getName, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_ternary_class_string_forwarded_to_conditional_return() {
let backend = create_test_backend();
let uri = Url::parse("file:///ternary_class_string.php").unwrap();
let text = concat!(
"<?php\n", "class Admin {\n", " public function getRole(): string {}\n", "}\n", "\n", "class Guest {\n", " public function getToken(): string {}\n", "}\n", "\n", "class Container {\n", " /**\n", " * @template T\n", " * @param class-string<T> $abstract\n", " * @return T\n", " */\n", " public function make(string $abstract): object {}\n", "}\n", "\n", "class Handler {\n", " public function handle(bool $isAdmin): void {\n", " $container = new Container();\n", " $cls = $isAdmin ? Admin::class : Guest::class;\n", " $user = $container->make($cls);\n", " $user->\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 params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 23,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(params).await.unwrap();
assert!(
result.is_some(),
"Completion should return results for $user->"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getRole"),
"Should include getRole from Admin (ternary true branch), got: {:?}",
method_names
);
assert!(
method_names.contains(&"getToken"),
"Should include getToken from Guest (ternary false branch), got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_match_class_string_inline_chain() {
let backend = create_test_backend();
let uri = Url::parse("file:///match_class_string_inline.php").unwrap();
let text = concat!(
"<?php\n", "class Foo {\n", " public function fooMethod(): void {}\n", "}\n", "\n", "class Bar {\n", " public function barMethod(): void {}\n", "}\n", "\n", "class Container {\n", " /**\n", " * @template T\n", " * @param class-string<T> $abstract\n", " * @return T\n", " */\n", " public function make(string $abstract): object {}\n", "}\n", "\n", "class Runner {\n", " public function run(int $which): void {\n", " $container = new Container();\n", " $cls = match ($which) {\n", " 1 => Foo::class,\n", " 2 => Bar::class,\n", " };\n", " $container->make($cls)->\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 params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 25,
character: 38,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(params).await.unwrap();
assert!(
result.is_some(),
"Completion should return results for inline chain $container->make($cls)->"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"fooMethod"),
"Should include fooMethod from Foo via inline chain, got: {:?}",
method_names
);
assert!(
method_names.contains(&"barMethod"),
"Should include barMethod from Bar via inline chain, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_match_class_string_forwarded_to_static_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///match_class_string_static.php").unwrap();
let text = concat!(
"<?php\n", "class Widget {\n", " public function render(): string {}\n", "}\n", "\n", "class Factory {\n", " /**\n", " * @template T\n", " * @param class-string<T> $class\n", " * @return T\n", " */\n", " public static function create(string $class): object {}\n", "}\n", "\n", "class Builder {\n", " public function build(string $name): void {\n", " $cls = match ($name) {\n", " 'widget' => Widget::class,\n", " };\n", " $instance = Factory::create($cls);\n", " $instance->\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 params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 20,
character: 19,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(params).await.unwrap();
assert!(
result.is_some(),
"Completion should return results for $instance->"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"render"),
"Should resolve $cls from match through static Factory::create and show render, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_array_shape_substitution() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_shape.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class ShapeBase {\n",
" /** @return array{data: T, items: list<T>} */\n",
" public function getResult(): array {}\n",
"}\n",
"\n",
"class User {\n",
" public function getName(): string {}\n",
"}\n",
"\n",
"/**\n",
" * @extends ShapeBase<User>\n",
" */\n",
"class UserShapeChild extends ShapeBase {\n",
" public function test(): void {\n",
" $result = $this->getResult();\n",
" $result['data']->\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: 19,
character: 28,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Should resolve array shape data: T → User and show 'getName', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_object_shape_substitution() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_object_shape.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class ObjectShapeBase {\n",
" /** @return object{payload: T} */\n",
" public function fetch(): object {}\n",
"}\n",
"\n",
"class Order {\n",
" public function getTotal(): float {}\n",
"}\n",
"\n",
"/**\n",
" * @extends ObjectShapeBase<Order>\n",
" */\n",
"class OrderFetcher extends ObjectShapeBase {\n",
" public function test(): void {\n",
" $obj = $this->fetch();\n",
" $obj->payload->\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: 19,
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");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getTotal"),
"Should resolve object shape payload: T → Order and show 'getTotal', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_shape_string_key_variable_assignment() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_shape_assign.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class ShapeAssignBase {\n",
" /** @return array{data: T, items: list<T>} */\n",
" public function getResult(): array {}\n",
"}\n",
"\n",
"class Gift {\n",
" public function open(): string {}\n",
"}\n",
"\n",
"/**\n",
" * @extends ShapeAssignBase<Gift>\n",
" */\n",
"class GiftShapeAssign extends ShapeAssignBase {\n",
" public function test(): void {\n",
" $result = $this->getResult();\n",
" $first = $result['data'];\n",
" $first->\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: 20,
character: 16,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"open"),
"Should resolve $first from $result['data'] (shape key) to Gift and show 'open', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_generic_shape_chained_bracket_variable_assignment() {
let backend = create_test_backend();
let uri = Url::parse("file:///generics_shape_chain_assign.php").unwrap();
let text = concat!(
"<?php\n",
"/**\n",
" * @template T\n",
" */\n",
"class ShapeChainAssignBase {\n",
" /** @return array{data: T, items: list<T>} */\n",
" public function getResult(): array {}\n",
"}\n",
"\n",
"class Gift {\n",
" public function open(): string {}\n",
"}\n",
"\n",
"/**\n",
" * @extends ShapeChainAssignBase<Gift>\n",
" */\n",
"class GiftShapeChainAssign extends ShapeChainAssignBase {\n",
" public function test(): void {\n",
" $result = $this->getResult();\n",
" $first = $result['items'][0];\n",
" $first->\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: 20,
character: 16,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"open"),
"Should resolve $first from $result['items'][0] (shape + list<T>) to Gift and show 'open', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_shape_string_key_from_var_annotation() {
let backend = create_test_backend();
let uri = Url::parse("file:///shape_var_annotation.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string {}\n",
"}\n",
"\n",
"class Demo {\n",
" public function test(): void {\n",
" /** @var array{name: User, age: int} $data */\n",
" $data = getData();\n",
" $name = $data['name'];\n",
" $name->\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: 10,
character: 16,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"Should resolve $name from $data['name'] (shape via @var) to User and show 'getName', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_foreach_extends_generic_collection_single_level() {
let backend = create_test_backend();
let uri = Url::parse("file:///foreach_single_level.php").unwrap();
let text = concat!(
"<?php\n",
"class Item {\n",
" public function itemMethod(): void {}\n",
"}\n",
"\n",
"/**\n",
" * @template T\n",
" * @implements IteratorAggregate<T>\n",
" */\n",
"abstract class BaseCollection implements IteratorAggregate {}\n",
"\n",
"/**\n",
" * @extends BaseCollection<Item>\n",
" */\n",
"class ItemCollection extends BaseCollection {}\n",
"\n",
"class Demo {\n",
" function test() {\n",
" $items = new ItemCollection();\n",
" foreach ($items as $item) {\n",
" $item->\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: 20,
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_some(), "Completion should return results");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"itemMethod"),
"Foreach over ItemCollection @extends BaseCollection<Item> should resolve to Item and show 'itemMethod', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_multi_level_generic_collection_foreach() {
let backend = create_test_backend();
let uri = Url::parse("file:///multi_level_foreach.php").unwrap();
let text = concat!(
"<?php\n", "/**\n", " * @template T\n", " * @extends IteratorAggregate<T>\n", " */\n", "interface ReflectionCollection extends IteratorAggregate {}\n", "\n", "/**\n", " * @template T\n", " * @implements ReflectionCollection<T>\n", " */\n", "abstract class AbstractReflectionCollection implements ReflectionCollection {}\n", "\n", "/**\n", " * @extends AbstractReflectionCollection<ReflectionArgument>\n", " */\n", "class ReflectionArgumentCollection extends AbstractReflectionCollection {}\n", "\n", "class ReflectionArgument {\n", " public function argMethod(): void {}\n", "}\n", "\n", "class ReflectionNode {\n", " public function arguments(): ReflectionArgumentCollection {}\n", "}\n", "\n", "$node = new ReflectionNode();\n", "$collection = $node->arguments();\n", "\n", "foreach ($collection as $item) {\n", " $item->\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: 30,
character: 11,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"argMethod"),
"Multi-level generic foreach should resolve element type to ReflectionArgument and show 'argMethod', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_chain_level_generic_substitution_first() {
let backend = create_test_backend();
let uri = Url::parse("file:///chain_generic.php").unwrap();
let text = concat!(
"<?php\n",
"class Product {\n",
" public function getPrice(): float {}\n",
" public function getSku(): string {}\n",
"}\n",
"\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first() {}\n",
" /** @return TValue */\n",
" public function last() {}\n",
"}\n",
"\n",
"class ProductService {\n",
" /** @return Collection<int, Product> */\n",
" public function getProducts(): Collection {}\n",
"\n",
" function test() {\n",
" $this->getProducts()->first()->\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: 22,
character: 42,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getPrice"),
"Chain should resolve TValue→Product via Collection<int, Product>::first() and show 'getPrice', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getSku"),
"Chain should resolve TValue→Product via Collection<int, Product>::first() and show 'getSku', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_chain_level_generic_substitution_variable_assignment() {
let backend = create_test_backend();
let uri = Url::parse("file:///chain_generic_var.php").unwrap();
let text = concat!(
"<?php\n",
"class User {\n",
" public function getEmail(): string {}\n",
" public function getName(): string {}\n",
"}\n",
"\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /** @return TValue */\n",
" public function first() {}\n",
"}\n",
"\n",
"class UserRepo {\n",
" /** @return Collection<int, User> */\n",
" public function findAll(): Collection {}\n",
"\n",
" function test() {\n",
" $users = $this->findAll();\n",
" $first = $users->first();\n",
" $first->\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: 22,
character: 16,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getEmail"),
"Variable assigned from Collection<int, User>::first() should resolve to User and show 'getEmail', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getName"),
"Variable assigned from Collection<int, User>::first() should resolve to User and show 'getName', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_mixin_generic_substitution() {
let backend = create_test_backend();
let uri = Url::parse("file:///mixin_generic.php").unwrap();
let text = concat!(
"<?php\n",
"class Product {\n",
" public function getPrice(): float {}\n",
" public function getSku(): string {}\n",
"}\n",
"\n",
"/**\n",
" * @template TModel\n",
" */\n",
"class Builder {\n",
" /** @return TModel */\n",
" public function firstOrFail() {}\n",
" /** @return TModel */\n",
" public function find() {}\n",
"}\n",
"\n",
"/**\n",
" * @template TRelatedModel\n",
" * @mixin Builder<TRelatedModel>\n",
" */\n",
"class Relation {\n",
"}\n",
"\n",
"/**\n",
" * @extends Relation<Product>\n",
" */\n",
"class BelongsTo extends Relation {\n",
"}\n",
"\n",
"class OrderLine {\n",
" public function product(): BelongsTo {}\n",
"\n",
" function test() {\n",
" $this->product()->firstOrFail()->\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: 33,
character: 45,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getPrice"),
"Mixin generic substitution should resolve TModel→Product via @mixin Builder<TRelatedModel> and show 'getPrice', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getSku"),
"Mixin generic substitution should resolve TModel→Product via @mixin Builder<TRelatedModel> and show 'getSku', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_inherited_array_property_template_substitution() {
let backend = create_test_backend();
let uri = Url::parse("file:///t19_array_prop.php").unwrap();
let text = concat!(
"<?php\n",
"class Message {\n",
" public string $text;\n",
" public function getText(): string { return ''; }\n",
"}\n",
"\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /** @var array<TKey, TValue> */\n",
" public array $items = [];\n",
"\n",
" /** @return TValue|null */\n",
" public function first(): mixed { return null; }\n",
"}\n",
"\n",
"/** @extends Collection<int, Message> */\n",
"final class MessageCollection extends Collection {\n",
" public function test(): void {\n",
" foreach ($this->items as $item) {\n",
" $item->\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: 22,
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_some(),
"Completion should return results for $item-> inside foreach over $this->items"
);
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_or(&i.label))
.collect();
let prop_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getText"),
"Foreach over $this->items (array<int, Message>) should resolve $item to Message and show 'getText', got methods: {:?}",
method_names
);
assert!(
prop_names.contains(&"text"),
"Foreach over $this->items (array<int, Message>) should resolve $item to Message and show 'text', got props: {:?}",
prop_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_inherited_property_bracket_access_template_substitution() {
let backend = create_test_backend();
let uri = Url::parse("file:///t19_bracket.php").unwrap();
let text = concat!(
"<?php\n",
"class Task {\n",
" public string $title;\n",
" public function getTitle(): string { return ''; }\n",
"}\n",
"\n",
"/**\n",
" * @template T\n",
" */\n",
"class TypedList {\n",
" /** @var list<T> */\n",
" public array $data = [];\n",
"}\n",
"\n",
"/** @extends TypedList<Task> */\n",
"class TaskList extends TypedList {\n",
" public function demo(): void {\n",
" $first = $this->data[0];\n",
" $first->\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: 18,
character: 17,
},
},
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 $first-> after $this->data[0]"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getTitle"),
"Should resolve $this->data[0] to Task and show 'getTitle', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_param_resolves_to_bound_inside_body() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_body_bound.php").unwrap();
let text = concat!(
"<?php\n", "class Builder {\n", " public function where(string $col): static { return $this; }\n", " public function orderBy(string $col): static { return $this; }\n", "}\n", "\n", "class Country {}\n", "\n", "class ProductRepository {\n", " /**\n", " * @template T of Builder\n", " * @param T $query\n", " * @return T\n", " */\n", " private static function filterDisabled(Builder $query, Country $code): Builder {\n", " $query->\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: 16,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"where"),
"Should resolve T to Builder and show 'where', got: {:?}",
method_names
);
assert!(
method_names.contains(&"orderBy"),
"Should resolve T to Builder and show 'orderBy', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_method_template_union_bound_resolves_inside_body() {
let backend = create_test_backend();
let uri = Url::parse("file:///method_tpl_union_body.php").unwrap();
let text = concat!(
"<?php\n", "class Builder {\n", " public function where(string $col): static { return $this; }\n", "}\n", "\n", "class QueryBuilder {\n", " public function orderBy(string $col): static { return $this; }\n", " public function where(string $col): static { return $this; }\n", "}\n", "\n", "class Country {}\n", "\n", "class ProductRepository {\n", " /**\n", " * @template T of Builder|QueryBuilder\n", " * @param T $query\n", " * @return T\n", " */\n", " private static function filterDisabled(Builder $query, Country $code): Builder {\n", " $query->\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: 19,
character: 16,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"where"),
"Should resolve T to Builder|QueryBuilder and show 'where', got: {:?}",
method_names
);
assert!(
method_names.contains(&"orderBy"),
"Should resolve T to Builder|QueryBuilder and show 'orderBy', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_trait_method_template_param_resolves_inside_body() {
let backend = create_test_backend();
let uri = Url::parse("file:///trait_tpl_body.php").unwrap();
let text = concat!(
"<?php\n", "class QueryBuilder {\n", " public function where(string $col): static { return $this; }\n", "}\n", "\n", "class Relation {\n", " public function getQuery(): QueryBuilder {}\n", "}\n", "\n", "trait GetMarketTrait {\n", " /**\n", " * @template TRelation of Relation\n", " * @param TRelation $relation\n", " * @return TRelation\n", " */\n", " protected function whereCurrentMarket(Relation $relation): Relation {\n", " $relation->\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: 16,
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");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"getQuery"),
"Should resolve TRelation to Relation and show 'getQuery', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_function_template_param_resolves_to_bound_inside_body() {
let backend = create_test_backend();
let uri = Url::parse("file:///func_tpl_body_bound.php").unwrap();
let text = concat!(
"<?php\n", "class Model {\n", " public function save(): bool {}\n", " public function delete(): bool {}\n", "}\n", "\n", "/**\n", " * @template T of Model\n", " * @param T $entity\n", " * @return T\n", " */\n", "function persist(Model $entity): Model {\n", " $entity->\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: 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(), "Completion should return results");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"save"),
"Should resolve T to Model and show 'save', got: {:?}",
method_names
);
assert!(
method_names.contains(&"delete"),
"Should resolve T to Model and show 'delete', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_mixin_template_param_resolved_via_property_generic_type() {
let backend = create_test_backend();
let wrapper_uri = Url::parse("file:///Subclient.php").unwrap();
let wrapper_text = r#"<?php
/**
* @template TWraps of object
* @mixin TWraps
*/
class Subclient {
public function getApiInstance(): object {}
}
"#;
let api_uri = Url::parse("file:///EventsApi.php").unwrap();
let api_text = r#"<?php
class EventsApi {
public function createEvent(array $body): array {}
public function getEvents(string $filter): array {}
public function deleteEvent(string $id): void {}
}
"#;
let consumer_uri = Url::parse("file:///KlaviyoClient.php").unwrap();
let consumer_text = r#"<?php
class KlaviyoClient {
/** @var Subclient<EventsApi> */
public $Events;
function test() {
$this->Events->
}
}
"#;
for (uri, text) in [
(&wrapper_uri, wrapper_text),
(&api_uri, api_text),
(&consumer_uri, consumer_text),
] {
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
}
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: consumer_uri.clone(),
},
position: Position {
line: 6,
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 return results");
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"createEvent"),
"Mixin template param via @var Subclient<EventsApi> should expose 'createEvent', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getEvents"),
"Mixin template param via @var Subclient<EventsApi> should expose 'getEvents', got: {:?}",
method_names
);
assert!(
method_names.contains(&"deleteEvent"),
"Mixin template param via @var Subclient<EventsApi> should expose 'deleteEvent', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getApiInstance"),
"Wrapper's own method 'getApiInstance' should still be present, got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_template_inferred_from_closure_param_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///closure_param_template.php").unwrap();
let text = concat!(
"<?php\n", "class User {\n", " public function getName(): string {}\n", " public function getEmail(): string {}\n", "}\n", "\n", "class EventBus {\n", " /**\n", " * @template T\n", " * @param Closure(T): void $callback\n", " * @return T\n", " */\n", " public function listen(Closure $callback): mixed {}\n", "}\n", "\n", "function test() {\n", " $bus = new EventBus();\n", " $result = $bus->listen(fn(User $u): void => $u->save());\n", " $result->\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: 18,
character: 13,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getName"),
"T should be inferred as User from closure param type, showing 'getName', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getEmail"),
"T should be inferred as User from closure param type, showing 'getEmail', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_template_inferred_from_closure_return_type_reduce_pattern() {
let backend = create_test_backend();
let uri = Url::parse("file:///closure_reduce_template.php").unwrap();
let text = concat!(
"<?php\n", "class Decimal {\n", " public function add(Decimal $other): Decimal {}\n", " public function getValue(): string {}\n", "}\n", "\n", "class Collection {\n", " /**\n", " * @template TReduceReturnType\n", " * @param callable(TReduceReturnType, mixed): TReduceReturnType $cb\n", " * @param TReduceReturnType $initial\n", " * @return TReduceReturnType\n", " */\n", " public function reduce(callable $cb, mixed $initial = null): mixed {}\n", "}\n", "\n", "function test() {\n", " $c = new Collection();\n", " $total = $c->reduce(fn(Decimal $carry, $item): Decimal => $carry, new Decimal());\n", " $total->\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: 19,
character: 12,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"add"),
"TReduceReturnType should resolve to Decimal showing 'add', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getValue"),
"TReduceReturnType should resolve to Decimal showing 'getValue', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_template_inferred_from_closure_second_param() {
let backend = create_test_backend();
let uri = Url::parse("file:///closure_second_param_template.php").unwrap();
let text = concat!(
"<?php\n", "class Order {\n", " public function getTotal(): float {}\n", " public function getStatus(): string {}\n", "}\n", "\n", "class Processor {\n", " /**\n", " * @template T\n", " * @param Closure(int, T): void $cb\n", " * @return T\n", " */\n", " public function process(Closure $cb): mixed {}\n", "}\n", "\n", "function test() {\n", " $proc = new Processor();\n", " $item = $proc->process(fn(int $i, Order $o): void => null);\n", " $item->\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: 18,
character: 11,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getTotal"),
"T should be inferred as Order from second closure param, showing 'getTotal', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getStatus"),
"T should be inferred as Order from second closure param, showing 'getStatus', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_function_template_inferred_from_closure_param_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///func_closure_param_template.php").unwrap();
let text = concat!(
"<?php\n", "class Product {\n", " public function getPrice(): float {}\n", " public function getSku(): string {}\n", "}\n", "\n", "/**\n", " * @template T\n", " * @param Closure(T): void $cb\n", " * @return T\n", " */\n", "function tap(Closure $cb): mixed {}\n", "\n", "$result = tap(fn(Product $p): void => $p->save());\n", "$result->\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: 9,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getPrice"),
"T should be inferred as Product from closure param, showing 'getPrice', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getSku"),
"T should be inferred as Product from closure param, showing 'getSku', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_template_inferred_from_full_closure_param_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///full_closure_param_template.php").unwrap();
let text = concat!(
"<?php\n", "class Invoice {\n", " public function getAmount(): float {}\n", " public function isPaid(): bool {}\n", "}\n", "\n", "class Pipeline {\n", " /**\n", " * @template T\n", " * @param Closure(T): void $handler\n", " * @return T\n", " */\n", " public function handle(Closure $handler): mixed {}\n", "}\n", "\n", "function test() {\n", " $pipe = new Pipeline();\n", " $inv = $pipe->handle(function(Invoice $i): void { $i->process(); });\n", " $inv->\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: 18,
character: 10,
},
},
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 method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"getAmount"),
"T should be inferred as Invoice from full closure param, showing 'getAmount', got: {:?}",
method_names
);
assert!(
method_names.contains(&"isPaid"),
"T should be inferred as Invoice from full closure param, showing 'isPaid', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_reduce_two_template_params_union_callable() {
let backend = create_test_backend();
let uri = Url::parse("file:///reduce_two_tpl.php").unwrap();
let text = concat!(
"<?php\n", "class Decimal {\n", " public function add(Decimal $other): Decimal {}\n", " public function getValue(): string {}\n", "}\n", "\n", "class OrderProduct {\n", " public float $price;\n", "}\n", "\n", "/**\n", " * @template TKey\n", " * @template TValue\n", " */\n", "class Collection {\n", " /**\n", " * @template TReduceInitial\n", " * @template TReduceReturnType\n", " * @param callable(TReduceInitial|TReduceReturnType, TValue, TKey): TReduceReturnType $callback\n", " * @param TReduceInitial $initial\n", " * @return TReduceReturnType\n", " */\n", " public function reduce(callable $callback, mixed $initial = null): mixed {}\n", "}\n", "\n", "function test() {\n", " /** @var Collection<int, OrderProduct> $products */\n", " $products = new Collection();\n", " $total = $products->reduce(fn(Decimal $carry, OrderProduct $p): Decimal => $carry->add($p->price), new Decimal('0'));\n", " $total->\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: 29,
character: 12,
},
},
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 $total->"
);
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_or(&i.label))
.collect();
assert!(
method_names.contains(&"add"),
"TReduceReturnType should resolve to Decimal via closure return type, showing 'add', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getValue"),
"TReduceReturnType should resolve to Decimal via closure return type, showing 'getValue', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}