use std::collections::HashMap;
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_first_class_callable_function_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_func.php").unwrap();
let src = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string { return ''; }\n",
" public function getEmail(): string { return ''; }\n",
"}\n",
"function createUser(): User { return new User(); }\n",
"class Service {\n",
" public function run(): void {\n",
" $fn = createUser(...);\n",
" $fn()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 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_first_class_callable_instance_method_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_method.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 Factory {\n",
" public function create(): Product { return new Product(); }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $factory = new Factory();\n",
" $fn = $factory->create(...);\n",
" $fn()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 12, 15).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_first_class_callable_this_method_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_this.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 makeOrder(): Order { return new Order(); }\n",
" public function run(): void {\n",
" $fn = $this->makeOrder(...);\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_first_class_callable_static_method_return_type() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_static.php").unwrap();
let src = concat!(
"<?php\n",
"class Config {\n",
" public function get(string $key): string { return ''; }\n",
" public function has(string $key): bool { return false; }\n",
"}\n",
"class ConfigFactory {\n",
" public static function make(): Config { return new Config(); }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $fn = ConfigFactory::make(...);\n",
" $fn()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 11, 15).await;
let names = method_names(&items);
assert!(names.contains(&"get"), "Expected get in {:?}", names,);
assert!(names.contains(&"has"), "Expected has in {:?}", names,);
}
#[tokio::test]
async fn test_first_class_callable_self_static_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_self.php").unwrap();
let src = concat!(
"<?php\n",
"class Order {\n",
" public function getTotal(): float { return 0.0; }\n",
"}\n",
"class Service {\n",
" public static function makeOrder(): Order { return new Order(); }\n",
" public function run(): void {\n",
" $fn = self::makeOrder(...);\n",
" $fn()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 8, 15).await;
let names = method_names(&items);
assert!(
names.contains(&"getTotal"),
"Expected getTotal in {:?}",
names,
);
}
#[tokio::test]
async fn test_first_class_callable_chained_call() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_chain.php").unwrap();
let src = concat!(
"<?php\n",
"class Builder {\n",
" public function build(): Product { return new Product(); }\n",
"}\n",
"class Product {\n",
" public function getTitle(): string { return ''; }\n",
" public function getPrice(): float { return 0.0; }\n",
"}\n",
"function getBuilder(): Builder { return new Builder(); }\n",
"class Service {\n",
" public function run(): void {\n",
" $fn = getBuilder(...);\n",
" $fn()->build()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 12, 25).await;
let names = method_names(&items);
assert!(
names.contains(&"getTitle"),
"Expected getTitle in {:?}",
names,
);
assert!(
names.contains(&"getPrice"),
"Expected getPrice in {:?}",
names,
);
}
#[tokio::test]
async fn test_first_class_callable_assigned_result() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_assign.php").unwrap();
let src = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string { return ''; }\n",
" public function getAge(): int { return 0; }\n",
"}\n",
"function createUser(): User { return new User(); }\n",
"class Service {\n",
" public function run(): void {\n",
" $fn = createUser(...);\n",
" $user = $fn();\n",
" $user->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 10, 16).await;
let names = method_names(&items);
assert!(
names.contains(&"getName"),
"Expected getName in {:?}",
names,
);
assert!(names.contains(&"getAge"), "Expected getAge in {:?}", names,);
}
#[tokio::test]
async fn test_first_class_callable_cross_file() {
let composer_json = r#"{
"autoload": {
"psr-4": { "App\\": "src/" }
}
}"#;
let model_src = concat!(
"<?php\n",
"namespace App;\n",
"class Invoice {\n",
" public function getAmount(): float { return 0.0; }\n",
" public function getNumber(): string { return ''; }\n",
"}\n",
);
let factory_src = concat!(
"<?php\n",
"namespace App;\n",
"class InvoiceFactory {\n",
" public static function create(): Invoice { return new Invoice(); }\n",
"}\n",
);
let service_src = concat!(
"<?php\n",
"namespace App;\n",
"class Service {\n",
" public function run(): void {\n",
" $fn = InvoiceFactory::create(...);\n",
" $fn()->\n",
" }\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer_json,
&[
("src/Invoice.php", model_src),
("src/InvoiceFactory.php", factory_src),
("src/Service.php", service_src),
],
);
let uri = Url::from_file_path(_dir.path().join("src/Service.php")).unwrap();
let items = complete_at(&backend, &uri, service_src, 5, 15).await;
let names = method_names(&items);
assert!(
names.contains(&"getAmount"),
"Expected getAmount in {:?}",
names,
);
assert!(
names.contains(&"getNumber"),
"Expected getNumber in {:?}",
names,
);
}
#[tokio::test]
async fn test_first_class_callable_nullsafe_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_nullsafe.php").unwrap();
let src = concat!(
"<?php\n",
"class Formatter {\n",
" public function format(): string { return ''; }\n",
"}\n",
"class Config {\n",
" public function getFormatter(): Formatter { return new Formatter(); }\n",
"}\n",
"class Service {\n",
" private ?Config $config;\n",
" public function run(): void {\n",
" $fn = $this?->config?->getFormatter(...);\n",
" $fn()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 11, 15).await;
let names = method_names(&items);
let _ = names;
}
#[tokio::test]
async fn test_first_class_callable_resolves_to_closure() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_closure_members.php").unwrap();
let src = concat!(
"<?php\n",
"class Closure {\n",
" public function bindTo(object $newThis): Closure { return $this; }\n",
" public function call(object $newThis): mixed { return null; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $fn = strlen(...);\n",
" $fn->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 8, 13).await;
let names = method_names(&items);
assert!(names.contains(&"bindTo"), "Expected bindTo in {:?}", names,);
assert!(names.contains(&"call"), "Expected call in {:?}", names,);
}
#[tokio::test]
async fn test_first_class_callable_top_level() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_top_level.php").unwrap();
let src = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string { return ''; }\n",
"}\n",
"function getUser(): User { return new User(); }\n",
"$fn = getUser(...);\n",
"$fn()->\n",
);
let items = complete_at(&backend, &uri, src, 6, 7).await;
let names = method_names(&items);
assert!(
names.contains(&"getName"),
"Expected getName in {:?}",
names,
);
}
#[tokio::test]
async fn test_first_class_callable_reassignment() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_reassign.php").unwrap();
let src = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string { return ''; }\n",
"}\n",
"class Order {\n",
" public function getTotal(): float { return 0.0; }\n",
"}\n",
"function getUser(): User { return new User(); }\n",
"function getOrder(): Order { return new Order(); }\n",
"class Service {\n",
" public function run(): void {\n",
" $fn = getUser(...);\n",
" $fn = getOrder(...);\n",
" $fn()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 13, 15).await;
let names = method_names(&items);
assert!(
names.contains(&"getTotal"),
"Expected getTotal in {:?}",
names,
);
assert!(
!names.contains(&"getName"),
"Did not expect getName in {:?}",
names,
);
}
#[tokio::test]
async fn test_first_class_callable_result_property_access() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_prop.php").unwrap();
let src = concat!(
"<?php\n",
"class Address {\n",
" public string $city;\n",
" public string $street;\n",
"}\n",
"class User {\n",
" public Address $address;\n",
" public function getName(): string { return ''; }\n",
"}\n",
"function getUser(): User { return new User(); }\n",
"class Service {\n",
" public function run(): void {\n",
" $fn = getUser(...);\n",
" $fn()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 13, 15).await;
let names = method_names(&items);
let props = property_names(&items);
assert!(
names.contains(&"getName"),
"Expected getName in {:?}",
names,
);
assert!(
props.contains(&"address"),
"Expected address property in {:?}",
props,
);
}
#[tokio::test]
async fn test_first_class_callable_static_method_returning_nullable_self() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_nullable_self.php").unwrap();
let src = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string { return ''; }\n",
" public function getEmail(): string { return ''; }\n",
" public static function findByEmail(string $email): ?self { return null; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $finder = User::findByEmail(...);\n",
" $finder()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 19).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_first_class_callable_instance_method_returning_self() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_self_return.php").unwrap();
let src = concat!(
"<?php\n",
"class Builder {\n",
" public function reset(): self { return new self(); }\n",
" public function build(): string { return ''; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $builder = new Builder();\n",
" $fn = $builder->reset(...);\n",
" $fn()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 15).await;
let names = method_names(&items);
assert!(names.contains(&"reset"), "Expected reset in {:?}", names,);
assert!(names.contains(&"build"), "Expected build in {:?}", names,);
}
#[tokio::test]
async fn test_first_class_callable_static_method_returning_static() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/fcc_static_return.php").unwrap();
let src = concat!(
"<?php\n",
"class Model {\n",
" public function getId(): int { return 0; }\n",
" public function getName(): string { return ''; }\n",
" public static function make(string $name): static { return new static(); }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $fn = Model::make(...);\n",
" $fn()->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 15).await;
let names = method_names(&items);
assert!(names.contains(&"getId"), "Expected getId in {:?}", names,);
assert!(
names.contains(&"getName"),
"Expected getName in {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_literal_resolves_to_closure() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_literal_members.php").unwrap();
let src = concat!(
"<?php\n",
"class Closure {\n",
" public function bindTo(?object $newThis): ?Closure { return $this; }\n",
" public static function bind(Closure $closure, ?object $newThis): ?Closure { return $closure; }\n",
" public function call(object $newThis, mixed ...$args): mixed { return null; }\n",
"}\n",
"class User { public function getName(): string { return ''; } }\n",
"class Service {\n",
" public function run(): void {\n",
" $typedClosure = function(User $u): string { return $u->getName(); };\n",
" $typedClosure->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 10, 24).await;
let names = method_names(&items);
assert!(names.contains(&"bindTo"), "Expected bindTo in {:?}", names,);
assert!(names.contains(&"call"), "Expected call in {:?}", names);
}
#[tokio::test]
async fn test_arrow_function_resolves_to_closure() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/arrow_fn_members.php").unwrap();
let src = concat!(
"<?php\n",
"class Closure {\n",
" public function bindTo(?object $newThis): ?Closure { return $this; }\n",
" public static function bind(Closure $closure, ?object $newThis): ?Closure { return $closure; }\n",
" public function call(object $newThis, mixed ...$args): mixed { return null; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $typedArrow = fn(int $x): float => $x * 1.5;\n",
" $typedArrow->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 22).await;
let names = method_names(&items);
assert!(names.contains(&"bindTo"), "Expected bindTo in {:?}", names,);
assert!(names.contains(&"call"), "Expected call in {:?}", names);
}
#[tokio::test]
async fn test_closure_literal_bindto_chain() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_bindto_chain.php").unwrap();
let src = concat!(
"<?php\n",
"class Closure {\n",
" public function bindTo(?object $newThis): ?Closure { return $this; }\n",
" public function call(object $newThis, mixed ...$args): mixed { return null; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $fn = function(): void {};\n",
" $bound = $fn->bindTo($this);\n",
" $bound->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 16).await;
let names = method_names(&items);
assert!(
names.contains(&"bindTo"),
"Expected bindTo on chained result in {:?}",
names,
);
assert!(
names.contains(&"call"),
"Expected call on chained result in {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_no_params_resolves_to_closure() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_bare.php").unwrap();
let src = concat!(
"<?php\n",
"class Closure {\n",
" public function bindTo(?object $newThis): ?Closure { return $this; }\n",
" public function call(object $newThis, mixed ...$args): mixed { return null; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $bare = function() {};\n",
" $bare->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 8, 15).await;
let names = method_names(&items);
assert!(names.contains(&"bindTo"), "Expected bindTo in {:?}", names,);
assert!(names.contains(&"call"), "Expected call in {:?}", names);
}
#[tokio::test]
async fn test_closure_with_use_resolves_to_closure() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_use_clause.php").unwrap();
let src = concat!(
"<?php\n",
"class Closure {\n",
" public function bindTo(?object $newThis): ?Closure { return $this; }\n",
" public function call(object $newThis, mixed ...$args): mixed { return null; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $x = 42;\n",
" $fn = function() use ($x): int { return $x; };\n",
" $fn->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 13).await;
let names = method_names(&items);
assert!(names.contains(&"bindTo"), "Expected bindTo in {:?}", names,);
}
#[tokio::test]
async fn test_arrow_function_no_return_type_resolves_to_closure() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/arrow_no_return.php").unwrap();
let src = concat!(
"<?php\n",
"class Closure {\n",
" public function bindTo(?object $newThis): ?Closure { return $this; }\n",
" public function call(object $newThis, mixed ...$args): mixed { return null; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $arrow = fn($x) => $x * 2;\n",
" $arrow->\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 8, 16).await;
let names = method_names(&items);
assert!(names.contains(&"bindTo"), "Expected bindTo in {:?}", names,);
assert!(names.contains(&"call"), "Expected call in {:?}", names);
}
#[tokio::test]
async fn test_closure_top_level_resolves_to_closure() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_top_level.php").unwrap();
let src = concat!(
"<?php\n",
"class Closure {\n",
" public function bindTo(?object $newThis): ?Closure { return $this; }\n",
" public function call(object $newThis, mixed ...$args): mixed { return null; }\n",
"}\n",
"$greet = function(string $name): string { return \"Hello $name\"; };\n",
"$greet->\n",
);
let items = complete_at(&backend, &uri, src, 6, 8).await;
let names = method_names(&items);
assert!(
names.contains(&"bindTo"),
"Expected bindTo at top level in {:?}",
names,
);
}
static CLOSURE_STUB: &str = "\
<?php
final class Closure
{
/**
* @param ?object $newThis
* @param ?string $newScope
* @return ?Closure
*/
public function bindTo(?object $newThis, ?string $newScope = null): ?Closure {}
/**
* @param Closure $closure
* @param ?object $newThis
* @param ?string $newScope
* @return ?Closure
*/
public static function bind(Closure $closure, ?object $newThis, ?string $newScope = null): ?Closure {}
/**
* @param object $newThis
* @param mixed ...$args
* @return mixed
*/
public function call(object $newThis, mixed ...$args): mixed {}
}
";
#[tokio::test]
async fn test_closure_literal_in_namespace_resolves_via_stubs() {
let mut class_stubs: HashMap<&'static str, &'static str> = HashMap::new();
class_stubs.insert("Closure", CLOSURE_STUB);
let backend = phpantom_lsp::Backend::new_test_with_stubs(class_stubs);
let uri = tower_lsp::lsp_types::Url::parse("file:///test/ns_closure.php").unwrap();
let src = concat!(
"<?php\n",
"namespace Demo {\n",
" class Pen {\n",
" public function write(): string { return ''; }\n",
" }\n",
"\n",
" class ClosureMembersDemo {\n",
" public function run(): void {\n",
" $typedClosure = function(Pen $p): string { return $p->write(); };\n",
" $typedClosure->\n",
" }\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 28).await;
let names = method_names(&items);
assert!(
names.contains(&"bindTo"),
"Expected bindTo from Closure stubs inside namespace block, got: {:?}",
names,
);
assert!(
names.contains(&"call"),
"Expected call from Closure stubs inside namespace block, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_arrow_function_in_namespace_resolves_via_stubs() {
let mut class_stubs: HashMap<&'static str, &'static str> = HashMap::new();
class_stubs.insert("Closure", CLOSURE_STUB);
let backend = phpantom_lsp::Backend::new_test_with_stubs(class_stubs);
let uri = tower_lsp::lsp_types::Url::parse("file:///test/ns_arrow.php").unwrap();
let src = concat!(
"<?php\n",
"namespace App\\Service {\n",
" class ArrowDemo {\n",
" public function run(): void {\n",
" $fn = fn(int $x): float => $x * 1.5;\n",
" $fn->\n",
" }\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 5, 17).await;
let names = method_names(&items);
assert!(
names.contains(&"bindTo"),
"Expected bindTo from Closure stubs for arrow fn in namespace, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_first_class_callable_in_namespace_resolves_via_stubs() {
let mut class_stubs: HashMap<&'static str, &'static str> = HashMap::new();
class_stubs.insert("Closure", CLOSURE_STUB);
let backend = phpantom_lsp::Backend::new_test_with_stubs(class_stubs);
let uri = tower_lsp::lsp_types::Url::parse("file:///test/ns_fcc.php").unwrap();
let src = concat!(
"<?php\n",
"namespace Demo {\n",
" class Pen {\n",
" public function write(): string { return ''; }\n",
" }\n",
"\n",
" class FccDemo {\n",
" public function run(): void {\n",
" $fn = strlen(...);\n",
" $fn->\n",
" }\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 17).await;
let names = method_names(&items);
assert!(
names.contains(&"bindTo"),
"Expected bindTo from Closure stubs for first-class callable in namespace, got: {:?}",
names,
);
}