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_arrow_fn_param_inferred_from_generic_map() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_infer_map.php").unwrap();
let src = concat!(
"<?php\n",
"class User {\n",
" public function getName(): string { return ''; }\n",
" public function getEmail(): string { return ''; }\n",
"}\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /**\n",
" * @param callable(TValue): mixed $callback\n",
" * @return static\n",
" */\n",
" public function map(callable $callback): static {}\n",
"}\n",
"class UserService {\n",
" /** @return Collection<int, User> */\n",
" public function getUsers(): Collection {}\n",
" public function run(): void {\n",
" $users = $this->getUsers();\n",
" $users->map(fn($u) => $u->);\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 21, 34).await;
let names = method_names(&items);
assert!(
names.contains(&"getName"),
"Expected getName from inferred User type, got: {:?}",
names,
);
assert!(
names.contains(&"getEmail"),
"Expected getEmail from inferred User type, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_param_inferred_from_generic_each() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_infer_each.php").unwrap();
let src = concat!(
"<?php\n",
"class Product {\n",
" public function getPrice(): float { return 0.0; }\n",
" public function getSku(): string { return ''; }\n",
"}\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /**\n",
" * @param callable(TValue): void $callback\n",
" * @return static\n",
" */\n",
" public function each(callable $callback): static {}\n",
"}\n",
"class ProductService {\n",
" /** @return Collection<int, Product> */\n",
" public function getProducts(): Collection {}\n",
" public function run(): void {\n",
" $products = $this->getProducts();\n",
" $products->each(function ($item) {\n",
" $item->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 22, 19).await;
let names = method_names(&items);
assert!(
names.contains(&"getPrice"),
"Expected getPrice from inferred Product type, got: {:?}",
names,
);
assert!(
names.contains(&"getSku"),
"Expected getSku from inferred Product type, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_explicit_type_hint_takes_precedence() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_explicit_wins.php").unwrap();
let src = concat!(
"<?php\n",
"class Animal {\n",
" public function speak(): string { return ''; }\n",
"}\n",
"class Dog extends Animal {\n",
" public function fetch(): void {}\n",
"}\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /**\n",
" * @param callable(TValue): mixed $callback\n",
" * @return static\n",
" */\n",
" public function map(callable $callback): static {}\n",
"}\n",
"class Kennel {\n",
" /** @return Collection<int, Animal> */\n",
" public function getAnimals(): Collection {}\n",
" public function run(): void {\n",
" $animals = $this->getAnimals();\n",
" $animals->map(function (Dog $d) {\n",
" $d->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 24, 17).await;
let names = method_names(&items);
assert!(
names.contains(&"fetch"),
"Explicit Dog type should win; expected fetch in {:?}",
names,
);
}
#[tokio::test]
async fn test_no_inference_for_non_callable_param() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_no_infer.php").unwrap();
let src = concat!(
"<?php\n",
"class Formatter {\n",
" public function format(string $value): string { return ''; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $f = new Formatter();\n",
" $f->format(fn($x) => $x->);\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 7, 34).await;
let names = method_names(&items);
assert!(
names.is_empty() || !names.contains(&"format"),
"Should not infer a type from non-callable param, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_concrete_callable_param_inference() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_concrete_callable.php").unwrap();
let src = concat!(
"<?php\n",
"class Config {\n",
" public function get(string $key): string { return ''; }\n",
" public function set(string $key, string $val): void {}\n",
"}\n",
"class App {\n",
" /**\n",
" * @param callable(Config): void $callback\n",
" * @return void\n",
" */\n",
" public function configure(callable $callback): void {}\n",
"}\n",
"class Bootstrap {\n",
" public function run(): void {\n",
" $app = new App();\n",
" $app->configure(function ($cfg) {\n",
" $cfg->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 16, 18).await;
let names = method_names(&items);
assert!(
names.contains(&"get"),
"Expected get from inferred Config type, got: {:?}",
names,
);
assert!(
names.contains(&"set"),
"Expected set from inferred Config type, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_inferred_type_resolves_properties() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_infer_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",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /**\n",
" * @param callable(TValue): mixed $callback\n",
" * @return static\n",
" */\n",
" public function map(callable $callback): static {}\n",
"}\n",
"class Geometry {\n",
" /** @return Collection<int, Point> */\n",
" public function getPoints(): Collection {}\n",
" public function run(): void {\n",
" $points = $this->getPoints();\n",
" $points->map(fn($p) => $p->);\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 22, 35).await;
let props = property_names(&items);
assert!(
props.contains(&"x"),
"Expected property x from inferred Point type, got: {:?}",
props,
);
assert!(
props.contains(&"y"),
"Expected property y from inferred Point type, got: {:?}",
props,
);
let methods = method_names(&items);
assert!(
methods.contains(&"distanceTo"),
"Expected distanceTo from inferred Point type, got: {:?}",
methods,
);
}
#[tokio::test]
async fn test_static_method_callable_param_inference() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_static_infer.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 Processor {\n",
" /**\n",
" * @param callable(Order): void $handler\n",
" * @return void\n",
" */\n",
" public static function handle(callable $handler): void {}\n",
"}\n",
"class Runner {\n",
" public function run(): void {\n",
" Processor::handle(function ($order) {\n",
" $order->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 15, 21).await;
let names = method_names(&items);
assert!(
names.contains(&"getTotal"),
"Expected getTotal from inferred Order type, got: {:?}",
names,
);
assert!(
names.contains(&"getStatus"),
"Expected getStatus from inferred Order type, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_callable_at_non_zero_position() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_second_arg.php").unwrap();
let src = concat!(
"<?php\n",
"class Item {\n",
" public function getWeight(): float { return 0.0; }\n",
"}\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /**\n",
" * @param int $size\n",
" * @param callable(TValue): void $callback\n",
" * @return void\n",
" */\n",
" public function chunk(int $size, callable $callback): void {}\n",
"}\n",
"class Warehouse {\n",
" /** @return Collection<int, Item> */\n",
" public function getItems(): Collection {}\n",
" public function run(): void {\n",
" $items = $this->getItems();\n",
" $items->chunk(100, function ($item) {\n",
" $item->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 22, 19).await;
let names = method_names(&items);
assert!(
names.contains(&"getWeight"),
"Expected getWeight from inferred Item type at arg position 1, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_type_syntax_inference() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_type_syntax.php").unwrap();
let src = concat!(
"<?php\n",
"class Logger {\n",
" public function info(string $msg): void {}\n",
" public function error(string $msg): void {}\n",
"}\n",
"class Pipeline {\n",
" /**\n",
" * @param Closure(Logger): void $step\n",
" * @return static\n",
" */\n",
" public function pipe(\\Closure $step): static {}\n",
"}\n",
"class Runner {\n",
" public function run(): void {\n",
" $pipeline = new Pipeline();\n",
" $pipeline->pipe(fn($log) => $log->);\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 15, 42).await;
let names = method_names(&items);
assert!(
names.contains(&"info"),
"Expected info from inferred Logger type, got: {:?}",
names,
);
assert!(
names.contains(&"error"),
"Expected error from inferred Logger type, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_inference_on_this_method_call() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_this_call.php").unwrap();
let src = concat!(
"<?php\n",
"class Task {\n",
" public function execute(): bool { return true; }\n",
" public function getName(): string { return ''; }\n",
"}\n",
"class TaskRunner {\n",
" /**\n",
" * @param callable(Task): void $fn\n",
" * @return void\n",
" */\n",
" public function runWith(callable $fn): void {}\n",
" public function run(): void {\n",
" $this->runWith(function ($task) {\n",
" $task->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 13, 19).await;
let names = method_names(&items);
assert!(
names.contains(&"execute"),
"Expected execute from inferred Task type, got: {:?}",
names,
);
assert!(
names.contains(&"getName"),
"Expected getName from inferred Task type, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_this_in_callable_param_resolves_to_receiver() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_this_receiver.php").unwrap();
let src = concat!(
"<?php\n",
"trait Conditionable {\n",
" /**\n",
" * @param callable($this, mixed): $this $callback\n",
" * @return $this\n",
" */\n",
" public function when(bool $condition, callable $callback): static {}\n",
"}\n",
"class Builder {\n",
" use Conditionable;\n",
" public function where(string $col, mixed $val): static { return $this; }\n",
" public function orderBy(string $col): static { return $this; }\n",
"}\n",
"class UserController {\n",
" public function index(): void {\n",
" $builder = new Builder();\n",
" $builder->when(true, function ($query) {\n",
" $query->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 17, 21).await;
let names = method_names(&items);
assert!(
names.contains(&"where"),
"Expected 'where' from Builder (receiver), got: {:?}",
names,
);
assert!(
names.contains(&"orderBy"),
"Expected 'orderBy' from Builder (receiver), got: {:?}",
names,
);
}
#[tokio::test]
async fn test_static_in_callable_param_resolves_to_receiver() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_static_receiver.php").unwrap();
let src = concat!(
"<?php\n",
"class Pipeline {\n",
" /**\n",
" * @param callable(static): void $callback\n",
" * @return static\n",
" */\n",
" public function tap(callable $callback): static {}\n",
" public function send(mixed $data): static { return $this; }\n",
" public function through(array $pipes): static { return $this; }\n",
"}\n",
"class SomeService {\n",
" public function run(): void {\n",
" $pipeline = new Pipeline();\n",
" $pipeline->tap(function ($p) {\n",
" $p->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 14, 17).await;
let names = method_names(&items);
assert!(
names.contains(&"send"),
"Expected 'send' from Pipeline (receiver), got: {:?}",
names,
);
assert!(
names.contains(&"through"),
"Expected 'through' from Pipeline (receiver), got: {:?}",
names,
);
}
#[tokio::test]
async fn test_laravel_where_closure_param_resolves_to_receiver() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_laravel_where.php").unwrap();
let src = concat!(
"<?php\n",
"class Builder {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return static\n",
" */\n",
" public function where($column, $operator = null, $value = null): static { return $this; }\n",
" public function whereNotIn(string $col, array $vals): static { return $this; }\n",
" public function orWhere(string $col, mixed $val = null): static { return $this; }\n",
"}\n",
"class SomeService {\n",
" public function run(): void {\n",
" $builder = new Builder();\n",
" $builder->where(function ($q) {\n",
" $q->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 14, 17).await;
let names = method_names(&items);
assert!(
names.contains(&"whereNotIn"),
"Expected 'whereNotIn' from Builder via (\\Closure(static): mixed)|string|array, got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' from Builder via (\\Closure(static): mixed)|string|array, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_this_in_callable_param_static_call_resolves_to_receiver() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_this_static_call.php").unwrap();
let src = concat!(
"<?php\n",
"class QueryBuilder {\n",
" /**\n",
" * @param callable($this): void $callback\n",
" * @return static\n",
" */\n",
" public static function create(callable $callback): static {}\n",
" public function limit(int $n): static { return $this; }\n",
" public function offset(int $n): static { return $this; }\n",
"}\n",
"class Controller {\n",
" public function index(): void {\n",
" QueryBuilder::create(function ($qb) {\n",
" $qb->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 13, 18).await;
let names = method_names(&items);
assert!(
names.contains(&"limit"),
"Expected 'limit' from QueryBuilder (static receiver), got: {:?}",
names,
);
assert!(
names.contains(&"offset"),
"Expected 'offset' from QueryBuilder (static receiver), got: {:?}",
names,
);
}
#[tokio::test]
async fn test_this_in_callable_param_arrow_fn() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_this_arrow.php").unwrap();
let src = concat!(
"<?php\n",
"class Filterable {\n",
" /**\n",
" * @param callable($this): bool $predicate\n",
" * @return static\n",
" */\n",
" public function filter(callable $predicate): static {}\n",
" public function isActive(): bool { return true; }\n",
" public function getName(): string { return ''; }\n",
"}\n",
"class App {\n",
" public function run(): void {\n",
" $f = new Filterable();\n",
" $f->filter(fn($item) => $item->);\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 13, 39).await;
let names = method_names(&items);
assert!(
names.contains(&"isActive"),
"Expected 'isActive' from Filterable (receiver), got: {:?}",
names,
);
assert!(
names.contains(&"getName"),
"Expected 'getName' from Filterable (receiver), got: {:?}",
names,
);
}
#[tokio::test]
async fn test_explicit_bare_hint_uses_inferred_generics_for_foreach() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_explicit_generic_foreach.php").unwrap();
let src = concat!(
"<?php\n",
"class Customer {\n",
" public function isActiveMember(): bool { return true; }\n",
" public function getEmail(): string { return ''; }\n",
"}\n",
"/**\n",
" * @template TKey of array-key\n",
" * @template TValue\n",
" * @implements IteratorAggregate<TKey, TValue>\n",
" */\n",
"class Collection {\n",
" /** @return TValue|null */\n",
" public function first(): mixed { return null; }\n",
" /** @return int */\n",
" public function count(): int { return 0; }\n",
"}\n",
"/**\n",
" * @template TModel\n",
" */\n",
"class Builder {\n",
" /**\n",
" * @param callable(Collection<int, TModel>, int): mixed $callback\n",
" * @return bool\n",
" */\n",
" public function chunk(int $count, callable $callback): bool { return true; }\n",
" /** @return static */\n",
" public function where(string $col, mixed $val = null): static { return $this; }\n",
"}\n",
"class Service {\n",
" /** @return Builder<Customer> */\n",
" public function query(): Builder { return new Builder(); }\n",
" public function run(): void {\n",
" $this->query()->chunk(10, function (Collection $customers): void {\n",
" foreach ($customers as $customer) {\n",
" $customer->\n",
" }\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 34, 28).await;
let names = method_names(&items);
assert!(
names.contains(&"isActiveMember"),
"Expected isActiveMember from Customer via foreach over Collection<int, Customer>, got: {:?}",
names,
);
assert!(
names.contains(&"getEmail"),
"Expected getEmail from Customer via foreach over Collection<int, Customer>, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_static_call_chain_closure_param_direct() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_static_chain_direct.php").unwrap();
let src = concat!(
"<?php\n",
"class Customer {\n",
" public function isActiveMember(): bool { return true; }\n",
" public function getEmail(): string { return ''; }\n",
"}\n",
"/**\n",
" * @template TKey of array-key\n",
" * @template TValue\n",
" * @implements IteratorAggregate<TKey, TValue>\n",
" */\n",
"class Collection {\n",
" /** @return TValue|null */\n",
" public function first(): mixed { return null; }\n",
" /** @return int */\n",
" public function count(): int { return 0; }\n",
"}\n",
"/**\n",
" * @template TModel\n",
" */\n",
"class Builder {\n",
" /**\n",
" * @param callable(Collection<int, TModel>): mixed $callback\n",
" * @return bool\n",
" */\n",
" public function each(callable $callback): bool { return true; }\n",
" /** @return static */\n",
" public function where(string $col, mixed $val = null): static { return $this; }\n",
"}\n",
"class Customer2 {\n",
" /** @return Builder<Customer> */\n",
" public static function where(string $col, mixed $val = null): Builder { return new Builder(); }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" Customer2::where('active', true)->each(function ($items) {\n",
" $items->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 35, 20).await;
let names = method_names(&items);
assert!(
names.contains(&"first"),
"Expected 'first' from Collection on closure param inferred via static call chain, got: {:?}",
names,
);
assert!(
names.contains(&"count"),
"Expected 'count' from Collection on closure param inferred via static call chain, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_explicit_bare_hint_via_static_call_chain_for_foreach() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_static_chain_foreach.php").unwrap();
let src = concat!(
"<?php\n",
"class Customer {\n",
" public function isActiveMember(): bool { return true; }\n",
" public function getEmail(): string { return ''; }\n",
"}\n",
"/**\n",
" * @template TKey of array-key\n",
" * @template TValue\n",
" * @implements IteratorAggregate<TKey, TValue>\n",
" */\n",
"class Collection {\n",
" /** @return TValue|null */\n",
" public function first(): mixed { return null; }\n",
" /** @return int */\n",
" public function count(): int { return 0; }\n",
"}\n",
"/**\n",
" * @template TModel\n",
" */\n",
"class Builder {\n",
" /**\n",
" * @param callable(Collection<int, TModel>, int): mixed $callback\n",
" * @return bool\n",
" */\n",
" public function chunk(int $count, callable $callback): bool { return true; }\n",
" /** @return static */\n",
" public function where(string $col, mixed $val = null): static { return $this; }\n",
"}\n",
"/**\n",
" * @extends Builder<Customer>\n",
" */\n",
"class CustomerBuilder extends Builder {\n",
"}\n",
"class Customer2 {\n",
" /** @return Builder<Customer> */\n",
" public static function where(string $col, mixed $val = null): Builder { return new Builder(); }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" Customer2::where('active', true)->chunk(10, function (Collection $customers): void {\n",
" foreach ($customers as $customer) {\n",
" $customer->\n",
" }\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 41, 28).await;
let names = method_names(&items);
assert!(
names.contains(&"isActiveMember"),
"Expected isActiveMember from Customer via static call chain + foreach over Collection<int, Customer>, got: {:?}",
names,
);
assert!(
names.contains(&"getEmail"),
"Expected getEmail from Customer via static call chain + foreach over Collection<int, Customer>, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_inline_stubs_chunk_closure_foreach_resolution() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/inline_stubs_chunk_foreach.php").unwrap();
let src = concat!(
"<?php\n",
"namespace Demo {\n",
"\n",
"class BlogAuthor extends \\Illuminate\\Database\\Eloquent\\Model\n",
"{\n",
" public function posts(): mixed { return null; }\n",
" public function getName(): string { return ''; }\n",
" public function demo(): void\n",
" {\n",
" BlogAuthor::where('active', true)->chunk(100, function (\\Illuminate\\Support\\Collection $authors) {\n",
" foreach ($authors as $author) {\n",
" $author->\n",
" }\n",
" });\n",
" }\n",
"}\n",
"\n",
"} // end namespace Demo\n",
"\n",
"namespace Illuminate\\Database\\Eloquent {\n",
" abstract class Model {\n",
" /** @return \\Illuminate\\Database\\Eloquent\\Builder<static> */\n",
" public static function query() {}\n",
" }\n",
"\n",
" /**\n",
" * @template TModel of \\Illuminate\\Database\\Eloquent\\Model\n",
" * @mixin \\Illuminate\\Database\\Query\\Builder\n",
" */\n",
" class Builder implements \\Illuminate\\Contracts\\Database\\Eloquent\\Builder {\n",
" /** @use \\Illuminate\\Database\\Concerns\\BuildsQueries<TModel> */\n",
" use \\Illuminate\\Database\\Concerns\\BuildsQueries;\n",
"\n",
" /** @return $this */\n",
" public function where($column, $operator = null, $value = null) {}\n",
"\n",
" /** @return \\Illuminate\\Database\\Eloquent\\Collection<int, TModel> */\n",
" public function get($columns = ['*']) { return new Collection(); }\n",
" }\n",
"\n",
" /**\n",
" * @template TKey of array-key\n",
" * @template TModel of \\Illuminate\\Database\\Eloquent\\Model\n",
" */\n",
" class Collection {\n",
" /** @return TModel|null */\n",
" public function first(): mixed { return null; }\n",
" public function count(): int { return 0; }\n",
" }\n",
"}\n",
"\n",
"namespace Illuminate\\Contracts\\Database\\Eloquent {\n",
" interface Builder {}\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Eloquent\\Relations {\n",
" class HasMany {}\n",
" class HasOne {}\n",
" class BelongsTo {}\n",
" class BelongsToMany {}\n",
" class MorphOne {}\n",
" class MorphMany {}\n",
" class MorphTo {}\n",
" class MorphToMany {}\n",
" class HasManyThrough {}\n",
" class HasOneThrough {}\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Concerns {\n",
" /**\n",
" * @template TValue\n",
" */\n",
" trait BuildsQueries {\n",
" /** @return TValue|null */\n",
" public function first($columns = ['*']) { return null; }\n",
"\n",
" /**\n",
" * @param callable(\\Illuminate\\Support\\Collection<int, TValue>, int): mixed $callback\n",
" * @return bool\n",
" */\n",
" public function chunk(int $count, callable $callback): bool { return true; }\n",
" }\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Query {\n",
" class Builder {\n",
" /** @return $this */\n",
" public function orderBy($column, $direction = 'asc') { return $this; }\n",
" }\n",
"}\n",
"\n",
"namespace Illuminate\\Support {\n",
" /**\n",
" * @template TKey of array-key\n",
" * @template TValue\n",
" * @implements \\IteratorAggregate<TKey, TValue>\n",
" */\n",
" class Collection {\n",
" /** @return TValue|null */\n",
" public function first(): mixed { return null; }\n",
" /** @return int */\n",
" public function count(): int { return 0; }\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 11, 25).await;
let names = method_names(&items);
assert!(
names.contains(&"posts"),
"Expected 'posts' from BlogAuthor via chunk closure + foreach over Collection<int, BlogAuthor>, got: {:?}",
names,
);
assert!(
names.contains(&"getName"),
"Expected 'getName' from BlogAuthor via chunk closure + foreach over Collection<int, BlogAuthor>, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_example_php_exact_layout_chunk_foreach() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/example_php_layout.php").unwrap();
let src = concat!(
"<?php\n", "namespace Demo {\n", "\n", "class BlogAuthor extends \\Illuminate\\Database\\Eloquent\\Model\n", "{\n", " public function posts(): mixed { return null; }\n", " public function getName(): string { return ''; }\n", "}\n", "\n", "class ClosureParamInferenceDemo\n", "{\n", " public function demo(): void\n", " {\n", " BlogAuthor::where('active', true)->chunk(100, function (Collection $authors) {\n", " foreach ($authors as $author) {\n", " $author->\n", " }\n", " });\n", " }\n", "}\n", "\n", "} // end namespace Demo\n", "\n", "namespace Illuminate\\Database\\Eloquent {\n", " abstract class Model {\n", " /** @return \\Illuminate\\Database\\Eloquent\\Builder<static> */\n", " public static function query() {}\n", " }\n", "\n", " /**\n", " * @template TModel of \\Illuminate\\Database\\Eloquent\\Model\n", " * @mixin \\Illuminate\\Database\\Query\\Builder\n", " */\n", " class Builder implements \\Illuminate\\Contracts\\Database\\Eloquent\\Builder {\n", " /** @use \\Illuminate\\Database\\Concerns\\BuildsQueries<TModel> */\n", " use \\Illuminate\\Database\\Concerns\\BuildsQueries;\n", "\n", " /** @return $this */\n", " public function where($column, $operator = null, $value = null) {}\n", "\n", " /** @return \\Illuminate\\Database\\Eloquent\\Collection<int, TModel> */\n", " public function get($columns = ['*']) { return new Collection(); }\n", " }\n", "\n", " /**\n", " * @template TKey of array-key\n", " * @template TModel of \\Illuminate\\Database\\Eloquent\\Model\n", " */\n", " class Collection {\n", " /** @return TModel|null */\n", " public function first(): mixed { return null; }\n", " public function count(): int { return 0; }\n", " }\n", "}\n", "\n", "namespace Illuminate\\Contracts\\Database\\Eloquent {\n", " interface Builder {}\n", "}\n", "\n", "namespace Illuminate\\Database\\Eloquent\\Relations {\n", " class HasMany {}\n", " class HasOne {}\n", " class BelongsTo {}\n", " class BelongsToMany {}\n", " class MorphOne {}\n", " class MorphMany {}\n", " class MorphTo {}\n", " class MorphToMany {}\n", " class HasManyThrough {}\n", " class HasOneThrough {}\n", "}\n", "\n", "namespace Illuminate\\Database\\Concerns {\n", " /**\n", " * @template TValue\n", " */\n", " trait BuildsQueries {\n", " /** @return TValue|null */\n", " public function first($columns = ['*']) { return null; }\n", "\n", " /**\n", " * @param callable(\\Illuminate\\Support\\Collection<int, TValue>, int): mixed $callback\n", " * @return bool\n", " */\n", " public function chunk(int $count, callable $callback): bool { return true; }\n", " }\n", "}\n", "\n", "namespace Illuminate\\Database\\Query {\n", " class Builder {\n", " /** @return $this */\n", " public function orderBy($column, $direction = 'asc') { return $this; }\n", " }\n", "}\n", "\n", "namespace Illuminate\\Support {\n", " /**\n", " * @template TKey of array-key\n", " * @template TValue\n", " * @implements \\IteratorAggregate<TKey, TValue>\n", " */\n", " class Collection {\n", " /** @return TValue|null */\n", " public function first(): mixed { return null; }\n", " /** @return int */\n", " public function count(): int { return 0; }\n", " }\n", "}\n", );
let items = complete_at(&backend, &uri, src, 15, 25).await;
let names = method_names(&items);
assert!(
names.contains(&"posts"),
"Expected 'posts' from BlogAuthor (demo in separate class, bare Collection hint), got: {:?}",
names,
);
assert!(
names.contains(&"getName"),
"Expected 'getName' from BlogAuthor (demo in separate class, bare Collection hint), got: {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_param_inference_toplevel_no_namespace() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_toplevel_no_ns.php").unwrap();
let src = concat!(
"<?php\n",
"class QueryBuilder {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return static\n",
" */\n",
" public function where($column, $operator = null, $value = null): static { return $this; }\n",
" public function whereNotIn(string $col, array $vals): static { return $this; }\n",
" public function orWhere(string $col, mixed $val = null): static { return $this; }\n",
" public function orderBy(string $col, string $dir = 'asc'): static { return $this; }\n",
"}\n",
"$query = QueryBuilder::orderBy('id');\n",
"$query->where(function ($q): void {\n",
" $q->\n",
"});\n",
);
let items = complete_at(&backend, &uri, src, 13, 8).await;
let names = method_names(&items);
assert!(
names.contains(&"whereNotIn"),
"Expected 'whereNotIn' from QueryBuilder (no namespace), got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' from QueryBuilder (no namespace), got: {:?}",
names,
);
}
#[tokio::test]
async fn test_chained_static_call_closure_param_inference_no_namespace() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/chained_static_closure_no_ns.php").unwrap();
let src = concat!(
"<?php\n",
"class Model {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return static\n",
" */\n",
" public function where($column, $operator = null, $value = null): static { return $this; }\n",
" public function whereNotIn(string $col, array $vals): static { return $this; }\n",
" public function orWhere(string $col, mixed $val = null): static { return $this; }\n",
" /** @return static */\n",
" public static function orderBy(string $col, string $dir = 'asc'): static { return new static(); }\n",
"}\n",
"final class ProductFilter extends Model {}\n",
"$page = new \\stdClass();\n",
"$productFilters = ProductFilter::orderBy('name')\n",
" ->where(function ($query) use ($page): void {\n",
" $query->\n",
" });\n",
);
let items = complete_at(&backend, &uri, src, 16, 17).await;
let names = method_names(&items);
assert!(
names.contains(&"whereNotIn"),
"Expected 'whereNotIn' via chained static call closure param (no namespace), got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' via chained static call closure param (no namespace), got: {:?}",
names,
);
}
#[tokio::test]
async fn test_chained_static_call_closure_param_inference_with_namespace() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/chained_static_closure_ns.php").unwrap();
let src = concat!(
"<?php\n",
"namespace Luxplus\\Core\\Database\\Model\\Products\\Filters;\n",
"class Model {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return static\n",
" */\n",
" public function where($column, $operator = null, $value = null): static { return $this; }\n",
" public function whereNotIn(string $col, array $vals): static { return $this; }\n",
" public function orWhere(string $col, mixed $val = null): static { return $this; }\n",
" /** @return static */\n",
" public static function orderBy(string $col, string $dir = 'asc'): static { return new static(); }\n",
"}\n",
"final class ProductFilter extends Model {}\n",
"$page = new \\stdClass();\n",
"$productFilters = ProductFilter::orderBy('name')\n",
" ->where(function ($query) use ($page): void {\n",
" $query->\n",
" });\n",
);
let items = complete_at(&backend, &uri, src, 17, 17).await;
let names = method_names(&items);
assert!(
names.contains(&"whereNotIn"),
"Expected 'whereNotIn' via chained static call closure param (with namespace), got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' via chained static call closure param (with namespace), got: {:?}",
names,
);
}
#[tokio::test]
async fn test_split_chain_closure_param_inference_with_namespace() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/split_chain_closure_ns.php").unwrap();
let src = concat!(
"<?php\n",
"namespace Luxplus\\Core\\Database\\Model;\n",
"class Model {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return static\n",
" */\n",
" public function where($column, $operator = null, $value = null): static { return $this; }\n",
" public function whereNotIn(string $col, array $vals): static { return $this; }\n",
" public function orWhere(string $col, mixed $val = null): static { return $this; }\n",
" /** @return static */\n",
" public static function orderBy(string $col, string $dir = 'asc'): static { return new static(); }\n",
"}\n",
"final class EmailGenerator extends Model {}\n",
"$query = EmailGenerator::orderBy('id');\n",
"$query->where(function ($q): void {\n",
" $q->\n",
"});\n",
);
let items = complete_at(&backend, &uri, src, 16, 8).await;
let names = method_names(&items);
assert!(
names.contains(&"whereNotIn"),
"Expected 'whereNotIn' via split chain (namespace), got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' via split chain (namespace), got: {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_param_inference_toplevel_with_namespace() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_toplevel_with_ns.php").unwrap();
let src = concat!(
"<?php\n",
"namespace App\\Models;\n",
"class QueryBuilder {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return static\n",
" */\n",
" public function where($column, $operator = null, $value = null): static { return $this; }\n",
" public function whereNotIn(string $col, array $vals): static { return $this; }\n",
" public function orWhere(string $col, mixed $val = null): static { return $this; }\n",
" public function orderBy(string $col, string $dir = 'asc'): static { return $this; }\n",
"}\n",
"$query = QueryBuilder::orderBy('id');\n",
"$query->where(function ($q): void {\n",
" $q->\n",
"});\n",
);
let items = complete_at(&backend, &uri, src, 14, 8).await;
let names = method_names(&items);
assert!(
names.contains(&"whereNotIn"),
"Expected 'whereNotIn' from QueryBuilder (with namespace), got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' from QueryBuilder (with namespace), got: {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_param_inference_in_method_with_namespace() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_method_with_ns.php").unwrap();
let src = concat!(
"<?php\n",
"namespace App\\Models;\n",
"class QueryBuilder {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return static\n",
" */\n",
" public function where($column, $operator = null, $value = null): static { return $this; }\n",
" public function whereNotIn(string $col, array $vals): static { return $this; }\n",
" public function orWhere(string $col, mixed $val = null): static { return $this; }\n",
" public function orderBy(string $col, string $dir = 'asc'): static { return $this; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $query = QueryBuilder::orderBy('id');\n",
" $query->where(function ($q): void {\n",
" $q->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 16, 17).await;
let names = method_names(&items);
assert!(
names.contains(&"whereNotIn"),
"Expected 'whereNotIn' from QueryBuilder (method + namespace), got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' from QueryBuilder (method + namespace), got: {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_param_inference_child_class_static_chain_with_namespace() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_child_static_ns.php").unwrap();
let src = concat!(
"<?php\n",
"namespace Luxplus\\Core\\Database\\Model;\n",
"class Model {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return static\n",
" */\n",
" public function where($column, $operator = null, $value = null): static { return $this; }\n",
" public function whereNotIn(string $col, array $vals): static { return $this; }\n",
" public function orWhere(string $col, mixed $val = null): static { return $this; }\n",
" /** @return static */\n",
" public static function orderBy(string $col, string $dir = 'asc'): static { return new static(); }\n",
"}\n",
"final class EmailGenerator extends Model {\n",
"}\n",
"$query = EmailGenerator::orderBy('id');\n",
"$query->where(function ($q): void {\n",
" $q->\n",
"});\n",
);
let items = complete_at(&backend, &uri, src, 17, 8).await;
let names = method_names(&items);
assert!(
names.contains(&"whereNotIn"),
"Expected 'whereNotIn' from Model via child static chain + namespace, got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' from Model via child static chain + namespace, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_param_inference_child_class_static_chain_no_namespace() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_child_static_no_ns.php").unwrap();
let src = concat!(
"<?php\n",
"class Model {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return static\n",
" */\n",
" public function where($column, $operator = null, $value = null): static { return $this; }\n",
" public function whereNotIn(string $col, array $vals): static { return $this; }\n",
" public function orWhere(string $col, mixed $val = null): static { return $this; }\n",
" /** @return static */\n",
" public static function orderBy(string $col, string $dir = 'asc'): static { return new static(); }\n",
"}\n",
"final class EmailGenerator extends Model {\n",
"}\n",
"$query = EmailGenerator::orderBy('id');\n",
"$query->where(function ($q): void {\n",
" $q->\n",
"});\n",
);
let items = complete_at(&backend, &uri, src, 16, 8).await;
let names = method_names(&items);
assert!(
names.contains(&"whereNotIn"),
"Expected 'whereNotIn' from Model via child static chain (no namespace), got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' from Model via child static chain (no namespace), got: {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_param_inference_with_use_clause() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_use_clause.php").unwrap();
let src = concat!(
"<?php\n",
"class QueryBuilder {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return static\n",
" */\n",
" public function where($column, $operator = null, $value = null): static { return $this; }\n",
" public function whereNotIn(string $col, array $vals): static { return $this; }\n",
" public function orWhere(string $col, mixed $val = null): static { return $this; }\n",
" public function orderBy(string $col, string $dir = 'asc'): static { return $this; }\n",
"}\n",
"$page = new \\stdClass();\n",
"$query = QueryBuilder::orderBy('id');\n",
"$query->where(function ($q) use ($page): void {\n",
" $q->\n",
"});\n",
);
let items = complete_at(&backend, &uri, src, 14, 8).await;
let names = method_names(&items);
assert!(
names.contains(&"whereNotIn"),
"Expected 'whereNotIn' from QueryBuilder via closure with use() clause, got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' from QueryBuilder via closure with use() clause, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_param_inference_with_use_clause_and_namespace() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_use_clause_ns.php").unwrap();
let src = concat!(
"<?php\n",
"namespace App\\Models;\n",
"class QueryBuilder {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return static\n",
" */\n",
" public function where($column, $operator = null, $value = null): static { return $this; }\n",
" public function whereNotIn(string $col, array $vals): static { return $this; }\n",
" public function orWhere(string $col, mixed $val = null): static { return $this; }\n",
" public function orderBy(string $col, string $dir = 'asc'): static { return $this; }\n",
"}\n",
"$page = new \\stdClass();\n",
"$query = QueryBuilder::orderBy('id');\n",
"$query->where(function ($q) use ($page): void {\n",
" $q->\n",
"});\n",
);
let items = complete_at(&backend, &uri, src, 15, 8).await;
let names = method_names(&items);
assert!(
names.contains(&"whereNotIn"),
"Expected 'whereNotIn' from QueryBuilder via closure with use() + namespace, got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' from QueryBuilder via closure with use() + namespace, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_cross_file_closure_param_inference_with_namespace() {
let composer = r#"{"autoload": {"psr-4": {"App\\": "src/"}}}"#;
let model_file = concat!(
"<?php\n",
"namespace App\\Database;\n",
"class Model {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return static\n",
" */\n",
" public function where($column, $operator = null, $value = null): static { return $this; }\n",
" public function whereNotIn(string $col, array $vals): static { return $this; }\n",
" public function orWhere(string $col, mixed $val = null): static { return $this; }\n",
" /** @return static */\n",
" public static function orderBy(string $col, string $dir = 'asc'): static { return new static(); }\n",
"}\n",
);
let generator_file = concat!(
"<?php\n",
"namespace App\\Models;\n",
"use App\\Database\\Model;\n",
"final class EmailGenerator extends Model {\n",
"}\n",
);
let script_file = concat!(
"<?php\n",
"namespace App\\Script;\n",
"use App\\Models\\EmailGenerator;\n",
"$query = EmailGenerator::orderBy('id');\n",
"$query->where(function ($q): void {\n",
" $q->\n",
"});\n",
);
let (backend, dir) = create_psr4_workspace(
composer,
&[
("src/Database/Model.php", model_file),
("src/Models/EmailGenerator.php", generator_file),
("src/Script/run.php", script_file),
],
);
let uri = Url::from_file_path(dir.path().join("src/Script/run.php")).unwrap();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: script_file.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: 5,
character: 8,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let items = match backend.completion(completion_params).await.unwrap() {
Some(CompletionResponse::Array(items)) => items,
Some(CompletionResponse::List(list)) => list.items,
None => vec![],
};
let names = method_names(&items);
assert!(
names.contains(&"whereNotIn"),
"Expected 'whereNotIn' from Model via cross-file closure param inference, got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' from Model via cross-file closure param inference, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_cross_file_closure_param_inference_without_namespace() {
let composer = r#"{"autoload": {"psr-4": {"App\\": "src/"}}}"#;
let model_file = concat!(
"<?php\n",
"namespace App\\Database;\n",
"class Model {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return static\n",
" */\n",
" public function where($column, $operator = null, $value = null): static { return $this; }\n",
" public function whereNotIn(string $col, array $vals): static { return $this; }\n",
" public function orWhere(string $col, mixed $val = null): static { return $this; }\n",
" /** @return static */\n",
" public static function orderBy(string $col, string $dir = 'asc'): static { return new static(); }\n",
"}\n",
);
let generator_file = concat!(
"<?php\n",
"namespace App\\Models;\n",
"use App\\Database\\Model;\n",
"final class EmailGenerator extends Model {\n",
"}\n",
);
let script_file = concat!(
"<?php\n",
"use App\\Models\\EmailGenerator;\n",
"$query = EmailGenerator::orderBy('id');\n",
"$query->where(function ($q): void {\n",
" $q->\n",
"});\n",
);
let (backend, dir) = create_psr4_workspace(
composer,
&[
("src/Database/Model.php", model_file),
("src/Models/EmailGenerator.php", generator_file),
("src/Script/run.php", script_file),
],
);
let uri = Url::from_file_path(dir.path().join("src/Script/run.php")).unwrap();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: script_file.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: 4,
character: 8,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let items = match backend.completion(completion_params).await.unwrap() {
Some(CompletionResponse::Array(items)) => items,
Some(CompletionResponse::List(list)) => list.items,
None => vec![],
};
let names = method_names(&items);
assert!(
names.contains(&"whereNotIn"),
"Expected 'whereNotIn' from Model via cross-file (no namespace), got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' from Model via cross-file (no namespace), got: {:?}",
names,
);
}
#[tokio::test]
async fn test_cross_file_laravel_eloquent_builder_closure_param_inference() {
let composer = r#"{
"autoload": {
"psr-4": {
"App\\": "src/",
"Luxplus\\Core\\Database\\Model\\": "src/Models/",
"Illuminate\\Database\\Eloquent\\": "vendor/laravel/framework/src/Illuminate/Database/Eloquent/",
"Illuminate\\Database\\Query\\": "vendor/laravel/framework/src/Illuminate/Database/Query/"
}
}
}"#;
let eloquent_builder_file = concat!(
"<?php\n",
"namespace Illuminate\\Database\\Eloquent;\n",
"\n",
"/**\n",
" * @template TModel of Model\n",
" * @mixin \\Illuminate\\Database\\Query\\Builder\n",
" */\n",
"class Builder {\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return $this\n",
" */\n",
" public function where($column, $operator = null, $value = null, $boolean = 'and') { return $this; }\n",
"\n",
" /** @return $this */\n",
" public function orWhere($column, $operator = null, $value = null) { return $this; }\n",
"\n",
" /** @return $this */\n",
" public function whereNotIn(string $column, array $values) { return $this; }\n",
"\n",
" /** @return $this */\n",
" public function orderBy(string $column, string $direction = 'asc') { return $this; }\n",
"}\n",
);
let query_builder_file = concat!(
"<?php\n",
"namespace Illuminate\\Database\\Query;\n",
"\n",
"class Builder {\n",
" /** @return $this */\n",
" public function groupBy(...$groups) { return $this; }\n",
"}\n",
);
let model_file = concat!(
"<?php\n",
"namespace Illuminate\\Database\\Eloquent;\n",
"\n",
"abstract class Model {\n",
"}\n",
);
let email_generator_file = concat!(
"<?php\n",
"namespace Luxplus\\Core\\Database\\Model;\n",
"\n",
"use Illuminate\\Database\\Eloquent\\Model;\n",
"\n",
"final class EmailGenerator extends Model {\n",
"}\n",
);
let script_file = concat!(
"<?php\n",
"namespace App\\Http\\Controllers;\n",
"use Luxplus\\Core\\Database\\Model\\EmailGenerator;\n",
"$query = EmailGenerator::orderBy('id');\n",
"$query->where(function ($q): void {\n",
" $q->\n",
"});\n",
);
let (backend, dir) = create_psr4_workspace(
composer,
&[
(
"vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php",
eloquent_builder_file,
),
(
"vendor/laravel/framework/src/Illuminate/Database/Query/Builder.php",
query_builder_file,
),
(
"vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php",
model_file,
),
("src/Models/EmailGenerator.php", email_generator_file),
("src/Http/Controllers/script.php", script_file),
],
);
let uri = Url::from_file_path(dir.path().join("src/Http/Controllers/script.php")).unwrap();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: script_file.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: 5,
character: 8,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let items = match backend.completion(completion_params).await.unwrap() {
Some(CompletionResponse::Array(items)) => items,
Some(CompletionResponse::List(list)) => list.items,
None => vec![],
};
let names = method_names(&items);
assert!(
names.contains(&"where"),
"Expected 'where' on $q via Eloquent Builder closure param inference, got: {:?}",
names,
);
assert!(
names.contains(&"orWhere"),
"Expected 'orWhere' on $q via Eloquent Builder closure param inference, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_inferred_subclass_wins_over_explicit_parent_type_hint() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_inferred_subclass.php").unwrap();
let src = concat!(
"<?php\n",
"class Model {\n",
" public function save(): bool { return true; }\n",
"}\n",
"class BrandTranslation extends Model {\n",
" public function getLangCode(): string { return ''; }\n",
" public function getBrandName(): string { return ''; }\n",
"}\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /**\n",
" * @param callable(TValue): mixed $callback\n",
" * @return static\n",
" */\n",
" public function each(callable $callback): static {}\n",
"}\n",
"class BrandService {\n",
" /** @return Collection<int, BrandTranslation> */\n",
" public function getTranslations(): Collection {}\n",
" public function run(): void {\n",
" $translations = $this->getTranslations();\n",
" $translations->each(function (Model $brandTranslation) {\n",
" $brandTranslation->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 25, 31).await;
let names = method_names(&items);
assert!(
names.contains(&"getLangCode"),
"Inferred BrandTranslation should win over explicit Model; expected getLangCode in {:?}",
names,
);
assert!(
names.contains(&"getBrandName"),
"Inferred BrandTranslation should win over explicit Model; expected getBrandName in {:?}",
names,
);
assert!(
names.contains(&"save"),
"Inherited Model methods should still be present; expected save in {:?}",
names,
);
}
#[tokio::test]
async fn test_explicit_subclass_still_wins_over_inferred_parent() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_explicit_subclass.php").unwrap();
let src = concat!(
"<?php\n",
"class Animal {\n",
" public function speak(): string { return ''; }\n",
"}\n",
"class Cat extends Animal {\n",
" public function purr(): void {}\n",
"}\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /**\n",
" * @param callable(TValue): mixed $callback\n",
" * @return static\n",
" */\n",
" public function each(callable $callback): static {}\n",
"}\n",
"class Shelter {\n",
" /** @return Collection<int, Animal> */\n",
" public function getAnimals(): Collection {}\n",
" public function run(): void {\n",
" $animals = $this->getAnimals();\n",
" $animals->each(function (Cat $c) {\n",
" $c->\n",
" });\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 24, 17).await;
let names = method_names(&items);
assert!(
names.contains(&"purr"),
"Explicit Cat type should win over inferred Animal; expected purr in {:?}",
names,
);
}
#[tokio::test]
async fn test_closure_with_type_hint_nested_inside_arrow_fn() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_nested_in_arrow.php").unwrap();
let src = concat!(
"<?php\n",
"class Builder {\n",
" public function where(string $col, mixed $val = null): static { return $this; }\n",
" public function orderBy(string $col): static { return $this; }\n",
" /**\n",
" * @param string $relation\n",
" * @param (\\Closure(static): mixed)|null $callback\n",
" * @return static\n",
" */\n",
" public function whereHas(string $relation, ?callable $callback = null): static { return $this; }\n",
"}\n",
"class Relation {\n",
" public function whereHas(string $relation, ?callable $callback = null): static { return $this; }\n",
"}\n",
"class Service {\n",
" public function run(): void {\n",
" $items = [fn(Relation $r): Relation => $r->whereHas('locales', function (Builder $q): void {\n",
" $q->\n",
" })];\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 17, 16).await;
let names = method_names(&items);
assert!(
names.contains(&"where"),
"Expected where() from explicitly typed Builder $q nested in arrow fn, got: {:?}",
names,
);
assert!(
names.contains(&"orderBy"),
"Expected orderBy() from explicitly typed Builder $q nested in arrow fn, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_arrow_fn_with_explicit_hint_inside_switch_case() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_in_switch.php").unwrap();
let src = concat!(
"<?php\n",
"class DeviationMessage {\n",
" public string $type = '';\n",
" public int $productId = 0;\n",
" public int $lineNumber = 0;\n",
"}\n",
"class Checker {\n",
" /** @var array<int, DeviationMessage> */\n",
" private array $items = [];\n",
" public function check(string $kind): bool {\n",
" switch ($kind) {\n",
" case 'a':\n",
" return array_any($this->items, fn(DeviationMessage $item) => $item->type === 'x' && $item->productId === 1);\n",
" case 'b':\n",
" return array_any($this->items, fn(DeviationMessage $item) => $item->type === 'y' && $item->lineNumber === 2);\n",
" default:\n",
" return false;\n",
" }\n",
" }\n",
"}\n",
);
let items_a = complete_at(&backend, &uri, src, 12, 87).await;
let names_a = property_names(&items_a);
assert!(
names_a.contains(&"type"),
"Expected 'type' from DeviationMessage in switch case 'a', got: {:?}",
names_a,
);
assert!(
names_a.contains(&"productId"),
"Expected 'productId' from DeviationMessage in switch case 'a', got: {:?}",
names_a,
);
let items_b = complete_at(&backend, &uri, src, 14, 87).await;
let names_b = property_names(&items_b);
assert!(
names_b.contains(&"type"),
"Expected 'type' from DeviationMessage in switch case 'b', got: {:?}",
names_b,
);
assert!(
names_b.contains(&"lineNumber"),
"Expected 'lineNumber' from DeviationMessage in switch case 'b', got: {:?}",
names_b,
);
}
#[tokio::test]
async fn test_arrow_fn_with_explicit_hint_inside_if_condition() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_in_if_cond.php").unwrap();
let src = concat!(
"<?php\n",
"class DeviationMessage {\n",
" public string $type = '';\n",
" public int $productId = 0;\n",
"}\n",
"class Checker {\n",
" /** @var array<int, DeviationMessage> */\n",
" private array $items = [];\n",
" public function check(): void {\n",
" if (array_any($this->items, fn(DeviationMessage $item) => $item->type === 'x' && $item->productId === 1)) {\n",
" return;\n",
" }\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 9, 77).await;
let names = property_names(&items);
assert!(
names.contains(&"type"),
"Expected 'type' from DeviationMessage in if condition, got: {:?}",
names,
);
assert!(
names.contains(&"productId"),
"Expected 'productId' from DeviationMessage in if condition, got: {:?}",
names,
);
}
#[tokio::test]
async fn test_arrow_fn_in_if_condition_inside_switch_case() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/closure_if_in_switch.php").unwrap();
let src = concat!(
"<?php\n",
"class DeviationMessage {\n",
" public string $type = '';\n",
" public int $productId = 0;\n",
" public int $lineNumber = 0;\n",
"}\n",
"class Checker {\n",
" /** @var array<int, DeviationMessage> */\n",
" private array $items = [];\n",
" public function check(string $kind): void {\n",
" switch ($kind) {\n",
" case 'a':\n",
" if (array_any($this->items, fn(DeviationMessage $item) => $item->type === 'x' && $item->productId === 1)) {\n",
" return;\n",
" }\n",
" break;\n",
" case 'b':\n",
" if (array_any($this->items, fn(DeviationMessage $item) => $item->type === 'y' && $item->lineNumber === 2)) {\n",
" return;\n",
" }\n",
" break;\n",
" }\n",
" }\n",
"}\n",
);
let items_a = complete_at(&backend, &uri, src, 12, 81).await;
let names_a = property_names(&items_a);
assert!(
names_a.contains(&"type"),
"Expected 'type' from DeviationMessage in if-in-switch case 'a', got: {:?}",
names_a,
);
assert!(
names_a.contains(&"productId"),
"Expected 'productId' from DeviationMessage in if-in-switch case 'a', got: {:?}",
names_a,
);
let items_b = complete_at(&backend, &uri, src, 17, 81).await;
let names_b = property_names(&items_b);
assert!(
names_b.contains(&"type"),
"Expected 'type' from DeviationMessage in if-in-switch case 'b', got: {:?}",
names_b,
);
assert!(
names_b.contains(&"lineNumber"),
"Expected 'lineNumber' from DeviationMessage in if-in-switch case 'b', got: {:?}",
names_b,
);
}
#[tokio::test]
async fn test_arrow_fn_untyped_param_inferred_from_function_template_simple() {
let backend = create_test_backend();
let uri = Url::parse("file:///template_callable_simple.php").unwrap();
let src = concat!(
"<?php\n", "/**\n", " * @template TKey\n", " * @template TValue\n", " * @param array<TKey, TValue> $array\n", " * @param callable(TValue, TKey): bool $callback\n", " * @return bool\n", " */\n", "function array_any(array $array, callable $callback): bool { return false; }\n", "\n", "class Product {\n", " public int $amount = 0;\n", " public string $sku = '';\n", "}\n", "\n", "/** @var array<int, Product> $products */\n", "$products = [];\n", "array_any($products, fn($item) => $item->amount > 0);\n", );
let items = complete_at(&backend, &uri, src, 17, 42).await;
let props = property_names(&items);
assert!(
props.contains(&"amount"),
"Expected 'amount' inferred from function template binding, got props: {:?}",
props,
);
assert!(
props.contains(&"sku"),
"Expected 'sku' inferred from function template binding, got props: {:?}",
props,
);
}
#[tokio::test]
async fn test_arrow_fn_untyped_param_inferred_from_function_template() {
let backend = create_test_backend();
let uri = Url::parse("file:///template_callable_infer.php").unwrap();
let src = concat!(
"<?php\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" * @param array<TKey, TValue> $array\n",
" * @param callable(TValue, TKey): bool $callback\n",
" * @return bool\n",
" */\n",
"function array_any(array $array, callable $callback): bool { return false; }\n",
"\n",
"class PurchaseFileProduct {\n",
" public int $order_amount = 0;\n",
" public string $name = '';\n",
"}\n",
"\n",
"/**\n",
" * @template TKey\n",
" * @template TValue\n",
" */\n",
"class Collection {\n",
" /** @var array<TKey, TValue> */\n",
" public array $items = [];\n",
"}\n",
"\n",
"/** @extends Collection<int, PurchaseFileProduct> */\n",
"final class PurchaseFileProductCollection extends Collection {\n",
" public function hasIssues(): bool {\n",
" return array_any($this->items, fn($item) => $item->order_amount > 0);\n",
" }\n",
"}\n",
);
let items = complete_at(&backend, &uri, src, 27, 62).await;
let props = property_names(&items);
assert!(
props.contains(&"order_amount"),
"Expected 'order_amount' inferred from function template binding, got props: {:?}",
props,
);
assert!(
props.contains(&"name"),
"Expected 'name' inferred from function template binding, got props: {:?}",
props,
);
}