use crate::common::{create_psr4_workspace, create_test_backend};
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;
async fn complete_at(
backend: &phpantom_lsp::Backend,
uri: &Url,
src: &str,
line: u32,
character: u32,
) -> Vec<CompletionItem> {
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: src.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, character },
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
match backend.completion(completion_params).await.unwrap() {
Some(CompletionResponse::Array(items)) => items,
Some(CompletionResponse::List(list)) => list.items,
None => vec![],
}
}
fn method_names(items: &[CompletionItem]) -> Vec<&str> {
items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect()
}
fn property_names(items: &[CompletionItem]) -> Vec<&str> {
items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect()
}
#[tokio::test]
async fn test_closure_literal_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_invoke.php").unwrap();
let src = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string { return ''; }\n",
" public function getEmail(): string { return ''; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $fn = function(): User { return new User(); };\n",
" $fn()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 8, 15).await;
let names = method_names(&items);
assert!(
names.contains(&"getName"),
"Expected getName in {:?}",
names,
);
assert!(
names.contains(&"getEmail"),
"Expected getEmail in {:?}",
names,
);
}
#[tokio::test]
async fn test_arrow_function_literal_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/arrow_invoke.php").unwrap();
let src = concat!(
"<?php\n",
"class Product {\n",
" public function getPrice(): float { return 0.0; }\n",
" public function getTitle(): string { return ''; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $factory = fn(): Product => new Product();\n",
" $factory()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 8, 20).await;
let names = method_names(&items);
assert!(
names.contains(&"getPrice"),
"Expected getPrice in {:?}",
names,
);
assert!(
names.contains(&"getTitle"),
"Expected getTitle in {:?}",
names,
);
}
#[tokio::test]
async fn test_docblock_closure_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/docblock_closure.php").unwrap();
let src = concat!(
"<?php\n",
"class Order {\n",
" public function getTotal(): float { return 0.0; }\n",
" public function getStatus(): string { return ''; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" /** @var \\Closure(): Order $fn */\n",
" $fn = getCallback();\n",
" $fn()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 15).await;
let names = method_names(&items);
assert!(
names.contains(&"getTotal"),
"Expected getTotal in {:?}",
names,
);
assert!(
names.contains(&"getStatus"),
"Expected getStatus in {:?}",
names,
);
}
#[tokio::test]
async fn test_docblock_callable_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/docblock_callable.php").unwrap();
let src = concat!(
"<?php\n",
"class Response {\n",
" public function getBody(): string { return ''; }\n",
" public function getStatusCode(): int { return 200; }\n",
"}\n",
"class Handler {\n",
" /**\n",
" * @param callable(): Response $handler\n",
" */\n",
" public function process($handler): void {\n",
" $handler()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 10, 21).await;
let names = method_names(&items);
assert!(
names.contains(&"getBody"),
"Expected getBody in {:?}",
names,
);
assert!(
names.contains(&"getStatusCode"),
"Expected getStatusCode in {:?}",
names,
);
}
#[tokio::test]
async fn test_callable_with_params_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/callable_params.php").unwrap();
let src = concat!(
"<?php\n",
"class Config {\n",
" public function get(string $key): string { return ''; }\n",
" public function set(string $key, $val): void {}\n",
"}\n",
"class App {\n",
" /**\n",
" * @param callable(string,int): Config $builder\n",
" */\n",
" public function init($builder): void {\n",
" $builder('test', 1)->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 10, 30).await;
let names = method_names(&items);
assert!(names.contains(&"get"), "Expected get in {:?}", names,);
assert!(names.contains(&"set"), "Expected set in {:?}", names,);
}
#[tokio::test]
async fn test_variable_assigned_from_closure_invocation() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/var_closure_result.php").unwrap();
let src = concat!(
"<?php\n",
"class Customer {\n",
" public function getId(): int { return 1; }\n",
" public function getFullName(): string { return ''; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $factory = function(): Customer { return new Customer(); };\n",
" $result = $factory();\n",
" $result->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 17).await;
let names = method_names(&items);
assert!(names.contains(&"getId"), "Expected getId in {:?}", names,);
assert!(
names.contains(&"getFullName"),
"Expected getFullName in {:?}",
names,
);
}
#[tokio::test]
async fn test_variable_assigned_from_docblock_callable_invocation() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/var_callable_result.php").unwrap();
let src = concat!(
"<?php\n",
"class Item {\n",
" public function getWeight(): float { return 0.0; }\n",
"}\n",
"class Processor {\n",
" /**\n",
" * @param Closure(): Item $loader\n",
" */\n",
" public function handle($loader): void {\n",
" $item = $loader();\n",
" $item->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 10, 15).await;
let names = method_names(&items);
assert!(
names.contains(&"getWeight"),
"Expected getWeight in {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_with_use_clause_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_use.php").unwrap();
let src = concat!(
"<?php\n",
"class Logger {\n",
" public function log(string $msg): void {}\n",
" public function getLevel(): int { return 0; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $prefix = 'INFO';\n",
" $fn = function() use ($prefix): Logger { return new Logger(); };\n",
" $fn()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 15).await;
let names = method_names(&items);
assert!(names.contains(&"log"), "Expected log in {:?}", names,);
assert!(
names.contains(&"getLevel"),
"Expected getLevel in {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_invocation_top_level() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_toplevel.php").unwrap();
let src = concat!(
"<?php\n",
"class Widget {\n",
" public function render(): string { return ''; }\n",
" public function hide(): void {}\n",
"}\n",
"$maker = function(): Widget { return new Widget(); };\n",
"$maker()->\n",
);
let items = complete_at(&backend, &uri, src, 6, 10).await;
let names = method_names(&items);
assert!(names.contains(&"render"), "Expected render in {:?}", names,);
assert!(names.contains(&"hide"), "Expected hide in {:?}", names,);
}
#[tokio::test]
async fn test_closure_nullable_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_nullable.php").unwrap();
let src = concat!(
"<?php\n",
"class Session {\n",
" public function getId(): string { return ''; }\n",
" public function destroy(): void {}\n",
"}\n",
"class App {\n",
" public function run(): void {\n",
" $getter = function(): ?Session { return null; };\n",
" $getter()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 8, 19).await;
let names = method_names(&items);
assert!(names.contains(&"getId"), "Expected getId in {:?}", names,);
assert!(
names.contains(&"destroy"),
"Expected destroy in {:?}",
names,
);
}
#[tokio::test]
async fn test_callable_invocation_chain() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/callable_chain.php").unwrap();
let src = concat!(
"<?php\n",
"class Builder {\n",
" public function setName(string $n): self { return $this; }\n",
" public function build(): void {}\n",
"}\n",
"class Factory {\n",
" public function run(): void {\n",
" $make = function(): Builder { return new Builder(); };\n",
" $make()->setName('test')->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 8, 37).await;
let names = method_names(&items);
assert!(
names.contains(&"setName"),
"Expected setName in {:?}",
names,
);
assert!(names.contains(&"build"), "Expected build in {:?}", names,);
}
#[tokio::test]
async fn test_callable_invocation_cross_file() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[(
"src/Models/Entity.php",
concat!(
"<?php\n",
"namespace App\\Models;\n",
"\n",
"class Entity {\n",
" public function save(): bool { return true; }\n",
" public function delete(): void {}\n",
"}\n",
),
)],
);
let uri = Url::parse("file:///app.php").unwrap();
let text = concat!(
"<?php\n",
"class Repo {\n",
" public function handle(): void {\n",
" $factory = function(): \\App\\Models\\Entity { return new \\App\\Models\\Entity(); };\n",
" $factory()->\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: 4,
character: 20,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = backend.completion(completion_params).await.unwrap();
assert!(result.is_some(), "Completion should resolve $factory()->");
match result.unwrap() {
CompletionResponse::Array(items) => {
let 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!(names.contains(&"save"), "Expected save in {:?}", names,);
assert!(names.contains(&"delete"), "Expected delete in {:?}", names,);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_docblock_fqn_closure_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fqn_closure.php").unwrap();
let src = concat!(
"<?php\n",
"class Database {\n",
" public function query(): void {}\n",
" public function disconnect(): void {}\n",
"}\n",
"class App {\n",
" /**\n",
" * @param \\Closure(): Database $connector\n",
" */\n",
" public function boot($connector): void {\n",
" $connector()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 10, 22).await;
let names = method_names(&items);
assert!(names.contains(&"query"), "Expected query in {:?}", names,);
assert!(
names.contains(&"disconnect"),
"Expected disconnect in {:?}",
names,
);
}
#[tokio::test]
async fn test_callable_return_type_properties() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/callable_props.php").unwrap();
let src = concat!(
"<?php\n",
"class Point {\n",
" public float $x;\n",
" public float $y;\n",
" public function distanceTo(Point $other): float { return 0.0; }\n",
"}\n",
"class Geo {\n",
" public function run(): void {\n",
" /** @var callable(): Point $maker */\n",
" $maker = getMaker();\n",
" $maker()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 10, 19).await;
let names = method_names(&items);
let props = property_names(&items);
assert!(
names.contains(&"distanceTo"),
"Expected distanceTo in {:?}",
names,
);
assert!(props.contains(&"x"), "Expected property x in {:?}", props,);
assert!(props.contains(&"y"), "Expected property y in {:?}", props,);
}
#[tokio::test]
async fn test_inline_var_callable_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/inline_var_callable.php").unwrap();
let src = concat!(
"<?php\n",
"class Mailer {\n",
" public function send(): bool { return true; }\n",
" public function setSubject(string $s): self { return $this; }\n",
"}\n",
"class Notifier {\n",
" public function notify(): void {\n",
" /** @var Closure(): Mailer $fn */\n",
" $fn = getMailerFactory();\n",
" $fn()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 15).await;
let names = method_names(&items);
assert!(names.contains(&"send"), "Expected send in {:?}", names,);
assert!(
names.contains(&"setSubject"),
"Expected setSubject in {:?}",
names,
);
}
#[tokio::test]
async fn test_invoke_return_type_simple() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/invoke_simple.php").unwrap();
let src = concat!(
"<?php\n",
"class Result { public function getValue(): string {} }\n",
"class Invokable {\n",
" public function __invoke(): Result {}\n",
"}\n",
"$f = new Invokable();\n",
"$f()->\n",
);
let items = complete_at(&backend, &uri, src, 6, 6).await;
let methods = method_names(&items);
assert!(
methods.contains(&"getValue"),
"Expected getValue from __invoke() return type, got: {methods:?}"
);
}
#[tokio::test]
async fn test_invoke_return_type_chain() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/invoke_chain.php").unwrap();
let src = concat!(
"<?php\n",
"class Builder {\n",
" public function build(): Product {}\n",
"}\n",
"class Product { public function getTitle(): string {} }\n",
"class Factory {\n",
" public function __invoke(): Builder {}\n",
"}\n",
"$f = new Factory();\n",
"$f()->build()->\n",
);
let items = complete_at(&backend, &uri, src, 9, 15).await;
let methods = method_names(&items);
assert!(
methods.contains(&"getTitle"),
"Expected getTitle from chained __invoke()->build(), got: {methods:?}"
);
}
#[tokio::test]
async fn test_invoke_return_type_from_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/invoke_method.php").unwrap();
let src = concat!(
"<?php\n",
"class Output { public function render(): void {} }\n",
"class Renderer {\n",
" public function __invoke(): Output {}\n",
"}\n",
"class App {\n",
" public function getRenderer(): Renderer {}\n",
"}\n",
"$app = new App();\n",
"$r = $app->getRenderer();\n",
"$r()->\n",
);
let items = complete_at(&backend, &uri, src, 10, 6).await;
let methods = method_names(&items);
assert!(
methods.contains(&"render"),
"Expected render from method-returned __invoke(), got: {methods:?}"
);
}
#[tokio::test]
async fn test_invoke_return_type_assigned_new() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/invoke_new.php").unwrap();
let src = concat!(
"<?php\n",
"class Response { public function getStatus(): int {} }\n",
"class Handler {\n",
" public function __invoke(): Response {}\n",
"}\n",
"$h = new Handler();\n",
"$h()->\n",
);
let items = complete_at(&backend, &uri, src, 6, 6).await;
let methods = method_names(&items);
assert!(
methods.contains(&"getStatus"),
"Expected getStatus from $h = new Handler(); $h(), got: {methods:?}"
);
}
#[tokio::test]
async fn test_invoke_docblock_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/invoke_docblock.php").unwrap();
let src = concat!(
"<?php\n",
"class Item { public function getLabel(): string {} }\n",
"class Fetcher {\n",
" /** @return Item[] */\n",
" public function __invoke(): array {}\n",
"}\n",
"$f = new Fetcher();\n",
"foreach ($f() as $item) {\n",
" $item->\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 8, 11).await;
let methods = method_names(&items);
assert!(
methods.contains(&"getLabel"),
"Expected getLabel from __invoke() docblock @return Item[], got: {methods:?}"
);
}
#[tokio::test]
async fn test_invoke_parenthesized_property() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/invoke_paren_prop.php").unwrap();
let src = concat!(
"<?php\n",
"class InvPen4 { public function write(): void {} }\n",
"class MyInvoker4 {\n",
" public function __invoke(): InvPen4 {}\n",
"}\n",
"class InvApp4 {\n",
" private MyInvoker4 $invoker;\n",
" public function demo(): void {\n",
" ($this->invoker)()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 8, 28).await;
let methods = method_names(&items);
assert!(
methods.contains(&"write"),
"Expected write from ($this->invoker)() __invoke(), got: {methods:?}"
);
}