use crate::common::{
create_psr4_workspace, create_test_backend, create_test_backend_with_closure_stub,
create_test_backend_with_function_stubs, create_test_backend_with_stdclass_stub,
};
use phpantom_lsp::Backend;
use tower_lsp::lsp_types::*;
fn hover_at(
backend: &Backend,
uri: &str,
content: &str,
line: u32,
character: u32,
) -> Option<Hover> {
backend.update_ast(uri, content);
backend.handle_hover(uri, content, Position { line, character })
}
fn hover_text(hover: &Hover) -> &str {
match &hover.contents {
HoverContents::Markup(markup) => &markup.value,
_ => panic!("Expected MarkupContent"),
}
}
#[test]
fn hover_this_variable() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class User {
public function greet(): string {
return $this->name();
}
}
"#;
let hover = hover_at(&backend, uri, content, 3, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(text.contains("$this"), "should mention $this: {}", text);
assert!(text.contains("User"), "should resolve to User: {}", text);
}
#[test]
fn hover_variable_with_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Order {
public string $id;
}
class Service {
public function run(): void {
$order = new Order();
$order->id;
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 9).expect("expected hover");
let text = hover_text(&hover);
assert!(text.contains("$order"), "should mention $order: {}", text);
assert!(text.contains("Order"), "should resolve to Order: {}", text);
}
#[test]
fn hover_variable_without_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
function test() {
$x = 42;
echo $x;
}
"#;
let hover = hover_at(&backend, uri, content, 3, 10).expect("expected hover");
let text = hover_text(&hover);
assert!(text.contains("$x"), "should mention $x: {}", text);
}
#[test]
fn hover_ambiguous_variable_shows_union_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Lamp {
public function dim(): void {}
public function turnOff(): void {}
}
class Faucet {
public function drip(): void {}
public function turnOff(): void {}
}
class Consumer {
public function run(): void {
if (rand(0, 1)) {
$ambiguous = new Lamp();
} else {
$ambiguous = new Faucet();
}
$ambiguous->turnOff();
}
}
"#;
let hover = hover_at(&backend, uri, content, 18, 9).expect("expected hover on $ambiguous");
let text = hover_text(&hover);
assert!(
text.contains("Lamp") && text.contains("Faucet"),
"hover should show both union types Lamp and Faucet, got: {}",
text
);
assert!(
text.contains("---"),
"union hover should use a horizontal rule separator, got: {}",
text
);
}
#[test]
fn hover_ambiguous_variable_inside_if_branch_shows_single_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Lamp {
public function dim(): void {}
public function turnOff(): void {}
}
class Faucet {
public function drip(): void {}
public function turnOff(): void {}
}
class Consumer {
public function run(): void {
if (rand(0, 1)) {
$ambiguous = new Lamp();
$ambiguous->dim();
} else {
$ambiguous = new Faucet();
$ambiguous->drip();
}
$ambiguous->turnOff();
}
}
"#;
let hover = hover_at(&backend, uri, content, 15, 13).expect("expected hover inside if branch");
let text = hover_text(&hover);
assert!(
text.contains("Lamp"),
"inside the if branch, should show Lamp: {}",
text
);
assert!(
!text.contains("Faucet"),
"inside the if branch, should NOT show Faucet: {}",
text
);
let hover =
hover_at(&backend, uri, content, 18, 13).expect("expected hover inside else branch");
let text = hover_text(&hover);
assert!(
text.contains("Faucet"),
"inside the else branch, should show Faucet: {}",
text
);
assert!(
!text.contains("Lamp"),
"inside the else branch, should NOT show Lamp: {}",
text
);
let hover = hover_at(&backend, uri, content, 20, 9).expect("expected hover after if/else");
let text = hover_text(&hover);
assert!(
text.contains("Lamp") && text.contains("Faucet"),
"after the if/else, should show both Lamp and Faucet: {}",
text
);
}
#[test]
fn hover_union_member_access_shows_all_branches() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Lamp {
public function dim(): void {}
public function turnOff(): void {}
}
class Faucet {
public function drip(): void {}
public function turnOff(): void {}
}
class Consumer {
public function run(): void {
if (rand(0, 1)) {
$ambiguous = new Lamp();
} else {
$ambiguous = new Faucet();
}
$ambiguous->turnOff();
}
}
"#;
let hover = hover_at(&backend, uri, content, 18, 22).expect("expected hover on turnOff");
let text = hover_text(&hover);
assert!(
text.contains("Lamp") && text.contains("Faucet"),
"hover on union member should show both Lamp and Faucet, got: {}",
text
);
assert!(
text.contains("---"),
"union member hover should use a horizontal rule separator, got: {}",
text
);
}
#[test]
fn hover_union_member_access_deduplicates_via_common_interface() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
interface Switchable {
public function turnOff(): void;
}
class Lamp implements Switchable {
public function turnOff(): void {}
public function dim(): void {}
}
class Faucet implements Switchable {
public function turnOff(): void {}
public function drip(): void {}
}
class Consumer {
public function run(): void {
if (rand(0, 1)) {
$ambiguous = new Lamp();
} else {
$ambiguous = new Faucet();
}
$ambiguous->turnOff();
}
}
"#;
let hover = hover_at(&backend, uri, content, 22, 22).expect("expected hover on turnOff");
let text = hover_text(&hover);
assert!(
text.contains("Lamp") && text.contains("Faucet"),
"hover should show both Lamp and Faucet (each declares turnOff), got: {}",
text
);
}
#[test]
fn hover_union_member_access_shows_declaring_class_not_access_class() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class BaseDevice {
public function turnOff(): void {}
}
class Lamp extends BaseDevice {
public function dim(): void {}
}
class Faucet extends BaseDevice {
public function drip(): void {}
}
class Consumer {
public function run(): void {
if (rand(0, 1)) {
$ambiguous = new Lamp();
} else {
$ambiguous = new Faucet();
}
$ambiguous->turnOff();
}
}
"#;
let hover = hover_at(&backend, uri, content, 20, 22).expect("expected hover on turnOff");
let text = hover_text(&hover);
assert!(
text.contains("BaseDevice"),
"hover should show declaring class BaseDevice, got: {}",
text
);
assert!(
!text.contains("---"),
"should not have separator when both branches resolve to same declaring class, got: {}",
text
);
}
#[test]
fn hover_union_branch_only_member_shows_single_class() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Lamp {
public function dim(): void {}
public function turnOff(): void {}
}
class Faucet {
public function drip(): void {}
public function turnOff(): void {}
}
class Consumer {
public function run(): void {
if (rand(0, 1)) {
$ambiguous = new Lamp();
} else {
$ambiguous = new Faucet();
}
$ambiguous->dim();
}
}
"#;
let hover = hover_at(&backend, uri, content, 18, 22).expect("expected hover on dim");
let text = hover_text(&hover);
assert!(
text.contains("Lamp"),
"hover should show Lamp for branch-only member dim, got: {}",
text
);
assert!(
!text.contains("Faucet"),
"hover should NOT show Faucet for dim (only on Lamp), got: {}",
text
);
assert!(
!text.contains("---"),
"single-branch member should not have separator, got: {}",
text
);
}
#[test]
fn hover_active_on_parameter_definition_site() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Order { public string $id; }
class Service {
public function process(Order $query, string $genre): void {
$query;
}
}
"#;
let hover = hover_at(&backend, uri, content, 3, 35)
.expect("hover should be active on parameter $query");
let text = hover_text(&hover);
assert!(
text.contains("$query"),
"hover should show the parameter name: {}",
text
);
assert!(
text.contains("Order"),
"hover should show the resolved type Order: {}",
text
);
let hover = hover_at(&backend, uri, content, 3, 50)
.expect("hover should be active on parameter $genre");
let text = hover_text(&hover);
assert!(
text.contains("$genre") && text.contains("string"),
"hover should show the parameter name and type: {}",
text
);
}
#[test]
fn hover_parameter_definition_shows_docblock_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Pen { public function write(): string { return ''; } }
class Drawer {
/** @param list<Pen> $pens The pens to use. */
public function fill(array $pens): void {
$pens;
}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 33)
.expect("hover should be active on parameter $pens with docblock type");
let text = hover_text(&hover);
assert!(
text.contains("$pens"),
"hover should show the parameter name: {}",
text
);
}
#[test]
fn hover_parameter_definition_standalone_function() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Pen { public function write(): string { return ''; } }
/** @param Pen $tool The writing instrument. */
function draw(Pen $tool): void {
$tool;
}
"#;
let hover = hover_at(&backend, uri, content, 3, 19)
.expect("hover should be active on standalone function parameter $tool");
let text = hover_text(&hover);
assert!(
text.contains("$tool"),
"hover should show the parameter name: {}",
text
);
assert!(
text.contains("Pen"),
"hover should show the type Pen: {}",
text
);
}
#[test]
fn hover_active_on_foreach_variable_definition_site() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Item { public string $name; }
class Service {
/** @param Item[] $items */
public function run(array $items): void {
foreach ($items as $item) {
$item->name;
}
}
}
"#;
let hover = hover_at(&backend, uri, content, 5, 29)
.expect("hover should be active on foreach variable $item");
let text = hover_text(&hover);
assert!(text.contains("Item"), "should resolve to Item: {}", text);
}
#[test]
fn hover_active_on_catch_variable_definition_site() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
function risky(): void {
try {
throw new \Exception('oops');
} catch (\Exception $e) {
echo $e->getMessage();
}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 26)
.expect("hover should be active on catch variable $e");
let text = hover_text(&hover);
assert!(
text.contains("Exception"),
"should resolve to Exception: {}",
text
);
}
#[test]
fn hover_active_on_variable_assignment() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Order { public string $id; }
class Service {
public function run(): void {
$order = new Order();
$order->id;
}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 9)
.expect("hover should be active on assignment $order");
let text = hover_text(&hover);
assert!(text.contains("Order"), "should resolve to Order: {}", text);
}
#[test]
fn hover_method_call() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Calculator {
public function add(int $a, int $b): int {
return $a + $b;
}
public function run(): void {
$this->add(1, 2);
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(text.contains("add"), "should contain method name: {}", text);
assert!(text.contains("int $a"), "should show params: {}", text);
assert!(text.contains(": int"), "should show return type: {}", text);
assert!(
text.contains("Calculator"),
"should show owner class: {}",
text
);
}
#[test]
fn hover_static_method() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Factory {
public static function create(string $name): self {
return new self();
}
}
class Usage {
public function run(): void {
Factory::create('test');
}
}
"#;
let hover = hover_at(&backend, uri, content, 8, 18).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("create"),
"should contain method name: {}",
text
);
assert!(text.contains("static"), "should indicate static: {}", text);
assert!(
text.contains("string $name"),
"should show params: {}",
text
);
}
#[test]
fn hover_property_access() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Config {
public string $name;
public function show(): void {
echo $this->name;
}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 21).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("name"),
"should contain property name: {}",
text
);
assert!(text.contains("string"), "should show type: {}", text);
assert!(text.contains("Config"), "should show owner: {}", text);
}
#[test]
fn hover_static_property() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Registry {
public static int $count;
}
class Usage {
public function run(): void {
echo Registry::$count;
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 24).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("count"),
"should contain property name: {}",
text
);
assert!(text.contains("static"), "should indicate static: {}", text);
assert!(text.contains("int"), "should show type: {}", text);
}
#[test]
fn hover_class_constant() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Status {
const ACTIVE = 'active';
}
class Usage {
public function run(): void {
echo Status::ACTIVE;
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 22).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("ACTIVE"),
"should contain constant name: {}",
text
);
assert!(text.contains("Status"), "should show owner: {}", text);
}
#[test]
fn hover_class_reference() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Animal {
public string $species;
}
class Zoo {
public function adopt(Animal $pet): void {}
}
"#;
let hover = hover_at(&backend, uri, content, 5, 28).expect("expected hover");
let text = hover_text(&hover);
assert!(text.contains("class"), "should show class kind: {}", text);
assert!(text.contains("Animal"), "should show class name: {}", text);
}
#[test]
fn hover_interface_reference() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
interface Printable {
public function print(): void;
}
class Document implements Printable {
public function print(): void {}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 32).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("interface"),
"should show interface kind: {}",
text
);
assert!(
text.contains("Printable"),
"should show interface name: {}",
text
);
}
#[test]
fn hover_interface_extending_interface_no_duplicate_extends() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template TKey
* @template-covariant TValue
* @template-extends iterable<TKey, TValue>
*/
interface Traversable extends iterable {}
function test(Traversable $t): void {}
"#;
let hover = hover_at(&backend, uri, content, 8, 17).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("interface Traversable extends iterable"),
"should show extends once: {}",
text
);
let extends_count = text.matches("extends").count();
assert_eq!(
extends_count, 1,
"should contain 'extends' exactly once, got {}: {}",
extends_count, text
);
}
#[test]
fn hover_class_declaration_returns_none() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Represents a blog post.
*/
class BlogPost {
public string $title;
}
"#;
let hover = hover_at(&backend, uri, content, 4, 8);
assert!(
hover.is_none(),
"should not show hover on class declaration site"
);
}
#[test]
fn hover_class_declaration_disambiguates_by_namespace_returns_none() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace Demo {
class Builder {
public function demo(): void {}
}
}
namespace Illuminate\Contracts\Database\Eloquent {
/**
* @mixin \Illuminate\Database\Eloquent\Builder
*/
interface Builder {}
}
"#;
let hover = hover_at(&backend, uri, content, 11, 16);
assert!(
hover.is_none(),
"should not show hover on interface declaration site"
);
let hover = hover_at(&backend, uri, content, 2, 12);
assert!(
hover.is_none(),
"should not show hover on class declaration site"
);
}
#[test]
fn hover_abstract_class() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
abstract class Shape {
abstract public function area(): float;
}
class Circle extends Shape {
public function area(): float { return 3.14; }
}
"#;
let hover = hover_at(&backend, uri, content, 4, 23).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("abstract class"),
"should show abstract class: {}",
text
);
assert!(text.contains("Shape"), "should show class name: {}", text);
}
#[test]
fn hover_final_class() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
final class Singleton {
public static function instance(): self { return new self(); }
}
function test(Singleton $s): void {}
"#;
let hover = hover_at(&backend, uri, content, 4, 17).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("final class"),
"should show final class: {}",
text
);
}
#[test]
fn hover_self_keyword() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Foo {
public static function make(): self {
return new self();
}
}
"#;
let hover = hover_at(&backend, uri, content, 3, 20).expect("expected hover");
let text = hover_text(&hover);
assert!(text.contains("self"), "should mention self: {}", text);
assert!(text.contains("Foo"), "should resolve to Foo: {}", text);
}
#[test]
fn hover_parent_keyword() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Base {
public function hello(): string { return 'hi'; }
}
class Child extends Base {
public function hello(): string {
return parent::hello();
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(text.contains("parent"), "should mention parent: {}", text);
assert!(text.contains("Base"), "should resolve to Base: {}", text);
}
#[test]
fn hover_user_function() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
function greet(string $name): string {
return "Hello, $name!";
}
greet('World');
"#;
let hover = hover_at(&backend, uri, content, 4, 2).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("greet"),
"should contain function name: {}",
text
);
assert!(
text.contains("string $name"),
"should show params: {}",
text
);
assert!(
text.contains(": string"),
"should show return type: {}",
text
);
}
#[test]
fn hover_deprecated_method() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Legacy {
/**
* @deprecated Use newMethod() instead.
*/
public function oldMethod(): void {}
public function run(): void {
$this->oldMethod();
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("oldMethod"),
"should contain method name: {}",
text
);
assert!(
text.contains("🪦 **deprecated** Use newMethod() instead."),
"should show deprecated with message: {}",
text
);
}
#[test]
fn hover_deprecated_method_without_message() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Legacy {
/**
* @deprecated
*/
public function oldMethod(): void {}
public function run(): void {
$this->oldMethod();
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("🪦 **deprecated**"),
"should show bare deprecated: {}",
text
);
assert!(
!text.contains("🪦 **deprecated** "),
"should not have trailing text after deprecated: {}",
text
);
}
#[test]
fn hover_deprecated_class() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @deprecated Use NewApi instead.
*/
class OldApi {
public function run(): void {}
}
function test(OldApi $api): void {}
"#;
let hover = hover_at(&backend, uri, content, 7, 17).expect("expected hover");
let text = hover_text(&hover);
assert!(text.contains("OldApi"), "should show class name: {}", text);
assert!(
text.contains("🪦 **deprecated** Use NewApi instead."),
"should show deprecated with message: {}",
text
);
}
#[test]
fn hover_deprecated_property_shows_message() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Config {
/**
* @deprecated Use getDebugMode() instead.
*/
public bool $debug = false;
public function test(): void {
$this->debug;
}
}
"#;
let hover = hover_at(&backend, uri, content, 8, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("🪦 **deprecated** Use getDebugMode() instead."),
"should show deprecated with message: {}",
text
);
}
#[test]
fn hover_deprecated_constant_shows_message() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class HttpStatus {
/**
* @deprecated Use OK instead.
*/
const SUCCESS = 200;
const OK = 200;
}
$x = HttpStatus::SUCCESS;
"#;
let hover = hover_at(&backend, uri, content, 9, 20).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("🪦 **deprecated** Use OK instead."),
"should show deprecated with message: {}",
text
);
}
#[test]
fn hover_deprecated_function_shows_message() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @deprecated Use newHelper() instead.
*/
function oldHelper(): void {}
oldHelper();
"#;
let hover = hover_at(&backend, uri, content, 6, 4).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("🪦 **deprecated** Use newHelper() instead."),
"should show deprecated with message: {}",
text
);
}
#[test]
fn hover_cross_file_class() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": { "App\\": "src/" }
}
}"#,
&[
(
"src/Models/Product.php",
r#"<?php
namespace App\Models;
/**
* Represents a product in the catalog.
*/
class Product {
public string $name;
public float $price;
public function discount(float $percent): float {
return $this->price * (1 - $percent / 100);
}
}
"#,
),
(
"src/Service.php",
r#"<?php
namespace App;
use App\Models\Product;
class Service {
public function run(): void {
$p = new Product();
$p->discount(10);
}
}
"#,
),
],
);
let product_uri = format!(
"file://{}",
_dir.path().join("src/Models/Product.php").display()
);
let product_content =
std::fs::read_to_string(_dir.path().join("src/Models/Product.php")).unwrap();
backend.update_ast(&product_uri, &product_content);
let service_uri = format!("file://{}", _dir.path().join("src/Service.php").display());
let service_content = std::fs::read_to_string(_dir.path().join("src/Service.php")).unwrap();
let hover = hover_at(&backend, &service_uri, &service_content, 5, 20)
.expect("expected hover on Product");
let text = hover_text(&hover);
assert!(
text.contains("Product"),
"should resolve cross-file class: {}",
text
);
assert!(
text.contains("Represents a product"),
"should include docblock from cross-file class: {}",
text
);
}
#[test]
fn hover_cross_file_method() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": { "App\\": "src/" }
}
}"#,
&[
(
"src/Models/Item.php",
r#"<?php
namespace App\Models;
class Item {
public function getLabel(): string {
return 'label';
}
}
"#,
),
(
"src/Handler.php",
r#"<?php
namespace App;
use App\Models\Item;
class Handler {
public function process(): void {
$item = new Item();
$item->getLabel();
}
}
"#,
),
],
);
let item_uri = format!(
"file://{}",
_dir.path().join("src/Models/Item.php").display()
);
let item_content = std::fs::read_to_string(_dir.path().join("src/Models/Item.php")).unwrap();
backend.update_ast(&item_uri, &item_content);
let handler_uri = format!("file://{}", _dir.path().join("src/Handler.php").display());
let handler_content = std::fs::read_to_string(_dir.path().join("src/Handler.php")).unwrap();
let hover = hover_at(&backend, &handler_uri, &handler_content, 6, 16)
.expect("expected hover on getLabel");
let text = hover_text(&hover);
assert!(
text.contains("getLabel"),
"should resolve cross-file method: {}",
text
);
assert!(
text.contains(": string"),
"should show return type: {}",
text
);
assert!(text.contains("Item"), "should show owner class: {}", text);
}
#[test]
fn hover_enum_declaration() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Possible statuses for an order.
*/
enum OrderStatus: string {
case Pending = 'pending';
case Shipped = 'shipped';
}
function process(OrderStatus $status): void {}
"#;
let hover = hover_at(&backend, uri, content, 8, 20).expect("expected hover");
let text = hover_text(&hover);
assert!(text.contains("enum"), "should show enum kind: {}", text);
assert!(
text.contains("OrderStatus"),
"should show enum name: {}",
text
);
assert!(
text.contains("Possible statuses"),
"should include docblock: {}",
text
);
}
#[test]
fn hover_trait_reference() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Provides soft-delete functionality.
*/
trait SoftDeletes {
public function trash(): void {}
}
class Post {
use SoftDeletes;
}
"#;
let hover = hover_at(&backend, uri, content, 8, 10).expect("expected hover");
let text = hover_text(&hover);
assert!(text.contains("trait"), "should show trait kind: {}", text);
assert!(
text.contains("SoftDeletes"),
"should show trait name: {}",
text
);
assert!(
text.contains("Provides soft-delete"),
"should include docblock: {}",
text
);
}
#[test]
fn hover_shows_visibility() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Vault {
private string $secret;
protected int $level;
public function getSecret(): string {
echo $this->secret;
echo $this->level;
return $this->secret;
}
}
"#;
let hover = hover_at(&backend, uri, content, 5, 22).expect("expected hover on secret");
let text = hover_text(&hover);
assert!(
text.contains("private"),
"should show private visibility: {}",
text
);
let hover = hover_at(&backend, uri, content, 6, 22).expect("expected hover on level");
let text = hover_text(&hover);
assert!(
text.contains("protected"),
"should show protected visibility: {}",
text
);
}
#[test]
fn hover_inherited_method() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class BaseRepo {
public function findAll(): array {
return [];
}
}
class UserRepo extends BaseRepo {
public function run(): void {
$this->findAll();
}
}
"#;
let hover = hover_at(&backend, uri, content, 8, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("findAll"),
"should show inherited method: {}",
text
);
assert!(
text.contains(": array"),
"should show return type: {}",
text
);
assert!(
text.contains("BaseRepo"),
"should show declaring class BaseRepo, got: {}",
text
);
assert!(
!text.contains("class UserRepo"),
"should NOT show UserRepo as the owner class, got: {}",
text
);
}
#[test]
fn hover_inherited_static_method_shows_declaring_class() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
abstract class Model {
/** @deprecated */
public static function find(int $id): ?static { return null; }
}
class User extends Model {
public function toArray(): array { return []; }
}
function demo(): void {
User::find(1);
}
"#;
let hover = hover_at(&backend, uri, content, 9, 11).expect("expected hover");
let text = hover_text(&hover);
assert!(text.contains("find"), "should show method name: {}", text);
assert!(
text.contains("class Model"),
"should show declaring class Model, not User, got: {}",
text
);
assert!(
!text.contains("class User"),
"should NOT show User as the owner class, got: {}",
text
);
}
#[test]
fn hover_class_with_extends_and_implements() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
interface Loggable {
public function log(): void;
}
class Base {}
class App extends Base implements Loggable {
public function log(): void {}
}
function test(App $app): void {}
"#;
let hover = hover_at(&backend, uri, content, 8, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(text.contains("class App"), "should show class: {}", text);
assert!(
text.contains("extends") && text.contains("Base"),
"should show parent: {}",
text
);
assert!(
text.contains("implements") && text.contains("Loggable"),
"should show interfaces: {}",
text
);
}
#[test]
fn hover_on_whitespace_returns_none() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Foo {}
"#;
let hover = hover_at(&backend, uri, content, 1, 0);
assert!(hover.is_none(), "should not produce hover on blank line");
}
#[test]
fn hover_stub_function() {
let backend = create_test_backend_with_function_stubs();
let uri = "file:///test.php";
let content = r#"<?php
$x = str_contains('hello', 'ell');
"#;
let hover = hover_at(&backend, uri, content, 1, 8).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("str_contains"),
"should show function name: {}",
text
);
assert!(
text.contains("string $haystack"),
"should show params: {}",
text
);
assert!(text.contains(": bool"), "should show return type: {}", text);
}
#[test]
fn hover_shows_fqn() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace App\Models;
/**
* A customer entity.
*/
class Customer {
public string $email;
}
class Service {
public function run(): void {
$c = new Customer();
$c->email;
}
}
"#;
let hover = hover_at(&backend, uri, content, 12, 18).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("namespace App\\Models;"),
"should show namespace line: {}",
text
);
assert!(
text.contains("class Customer"),
"should show short class name: {}",
text
);
assert!(
text.contains("A customer entity"),
"should include docblock: {}",
text
);
}
#[test]
fn hover_method_with_reference_param() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Sorter {
public function sort(array &$items): void {}
public function run(): void {
$this->sort([]);
}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("&$items"),
"should show reference param: {}",
text
);
}
#[test]
fn hover_method_with_variadic_param() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Logger {
public function log(string ...$messages): void {}
public function run(): void {
$this->log('a', 'b');
}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("...$messages"),
"should show variadic param: {}",
text
);
}
#[test]
fn hover_class_in_array_shape_value_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Pen {
public string $color;
}
/**
* @return array{logger: Pen, debug: bool}
*/
function getAppConfig(): array { return []; }
"#;
let hover =
hover_at(&backend, uri, content, 5, 25).expect("expected hover on Pen in array shape");
let text = hover_text(&hover);
assert!(
text.contains("Pen"),
"should resolve Pen inside array shape, got: {}",
text
);
assert!(
text.contains("class"),
"should show class kind for Pen, got: {}",
text
);
}
#[test]
fn hover_class_in_callable_return_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Pencil {
public string $color;
}
class Factory {
/** @var \Closure(): Pencil $supplier */
private $supplier;
}
"#;
let hover = hover_at(&backend, uri, content, 5, 29).expect("expected hover on Pencil");
let text = hover_text(&hover);
assert!(
text.contains("Pencil"),
"should show Pencil class: {}",
text
);
assert!(
!text.contains("Closure(): Pencil"),
"should not treat whole callable as class name: {}",
text
);
}
#[test]
fn hover_class_in_callable_param_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Request {
public string $body;
}
class Response {
public int $status;
}
class Handler {
/** @var callable(Request): Response $handler */
private $handler;
}
"#;
let hover = hover_at(&backend, uri, content, 8, 24).expect("expected hover on Request");
let text = hover_text(&hover);
assert!(
text.contains("Request"),
"should show Request class: {}",
text
);
let hover = hover_at(&backend, uri, content, 8, 34).expect("expected hover on Response");
let text = hover_text(&hover);
assert!(
text.contains("Response"),
"should show Response class: {}",
text
);
}
#[test]
fn hover_closure_base_in_callable_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Result {}
class Worker {
/** @param \Closure(int): Result $cb */
public function run($cb) {}
}
"#;
let hover = hover_at(&backend, uri, content, 3, 35).expect("expected hover on Result");
let text = hover_text(&hover);
assert!(
text.contains("Result"),
"should show Result class: {}",
text
);
}
#[test]
fn hover_property_shows_docblock_description() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Zoo {
/** @var list<string> The animal names */
public array $animals;
public function show(): void {
echo $this->animals;
}
}
"#;
let hover = hover_at(&backend, uri, content, 5, 22).expect("expected hover on animals");
let text = hover_text(&hover);
assert!(
text.contains("The animal names"),
"should include docblock description: {}",
text
);
assert!(
text.contains("@var list<string>"),
"should show effective docblock type as @var annotation: {}",
text
);
}
#[test]
fn hover_method_shows_docblock_description() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Greeter {
/**
* Say hello to someone.
* @param string $name The person's name
* @return string
*/
public function greet(string $name): string {
return "Hello, $name!";
}
public function run(): void {
$this->greet('World');
}
}
"#;
let hover = hover_at(&backend, uri, content, 11, 16).expect("expected hover on greet");
let text = hover_text(&hover);
assert!(
text.contains("Say hello to someone."),
"should include method docblock description: {}",
text
);
}
#[test]
fn hover_constant_shows_docblock_description() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Config {
/** The maximum retry count. */
const MAX_RETRIES = 3;
}
class Worker {
public function run(): void {
echo Config::MAX_RETRIES;
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 22).expect("expected hover on MAX_RETRIES");
let text = hover_text(&hover);
assert!(
text.contains("The maximum retry count."),
"should include constant docblock description: {}",
text
);
}
#[test]
fn hover_property_shows_native_type_in_code_block_and_effective_as_annotation() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace Demo;
class Pen {
public string $color;
}
class ScaffoldingIteration {
/** @var list<Pen> The batches */
public array $batch;
public function show(): void {
echo $this->batch;
}
}
"#;
let hover = hover_at(&backend, uri, content, 9, 22).expect("expected hover on batch");
let text = hover_text(&hover);
assert!(
text.contains("@var list<Pen>"),
"should show effective docblock type as @var annotation with short names: {}",
text
);
assert!(
text.contains("The batches"),
"should show docblock description: {}",
text
);
assert!(
text.contains("public array $batch;"),
"should show native type in PHP code block: {}",
text
);
assert!(
text.contains("namespace Demo;"),
"should show namespace line: {}",
text
);
assert!(
text.contains("class ScaffoldingIteration {"),
"should show short owning class name: {}",
text
);
}
#[test]
fn hover_property_without_docblock_type_shows_native_in_both() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Simple {
public string $name;
public function show(): void {
echo $this->name;
}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 22).expect("expected hover on name");
let text = hover_text(&hover);
assert!(
text.contains("public string $name;"),
"should show native type in code block: {}",
text
);
}
#[test]
fn hover_method_shows_namespace_and_short_names_in_code_block() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace App;
class User {
public string $email;
}
class UserRepo {
/**
* Find all users.
* @return list<User>
*/
public function findAll(): array {
return [];
}
public function run(): void {
$this->findAll();
}
}
"#;
let hover = hover_at(&backend, uri, content, 14, 16).expect("expected hover on findAll");
let text = hover_text(&hover);
assert!(
text.contains("**return** `list<User>`"),
"should show effective return type with short names in return section: {}",
text
);
assert!(
text.contains("function findAll(): array;"),
"should show native return type in PHP code block: {}",
text
);
assert!(
text.contains("Find all users."),
"should show method docblock description: {}",
text
);
assert!(
text.contains("namespace App;"),
"should show namespace line: {}",
text
);
assert!(
text.contains("class UserRepo {"),
"should show short owning class name: {}",
text
);
}
#[test]
fn hover_contains_php_open_tag() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Box {
public int $size;
public function show(): void {
echo $this->size;
}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 22).expect("expected hover on size");
let text = hover_text(&hover);
assert!(
text.contains("<?php"),
"should contain <?php marker in code block: {}",
text
);
}
#[test]
fn hover_function_shows_description_and_native_return() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Calculate the sum of values.
* @param list<int> $values
* @return int
*/
function total(array $values): int {
return array_sum($values);
}
total([1, 2, 3]);
"#;
let hover = hover_at(&backend, uri, content, 9, 2).expect("expected hover on total");
let text = hover_text(&hover);
assert!(
text.contains("Calculate the sum of values."),
"should show function docblock description: {}",
text
);
assert!(
text.contains("<?php"),
"should contain <?php marker: {}",
text
);
}
#[test]
fn hover_variable_shows_type_in_code_block() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Order {
public string $id;
}
class Service {
public function run(): void {
$order = new Order();
$order->id;
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 9).expect("expected hover on $order");
let text = hover_text(&hover);
assert!(
text.contains("$order = Order"),
"should show variable with type in code block: {}",
text
);
assert!(
text.contains("<?php"),
"should contain <?php marker: {}",
text
);
}
#[test]
fn hover_variable_without_type_shows_php_tag() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
function test() {
$x = 42;
echo $x;
}
"#;
let hover = hover_at(&backend, uri, content, 3, 10).expect("expected hover on $x");
let text = hover_text(&hover);
assert!(
text.contains("<?php"),
"should contain <?php marker for unresolved variable: {}",
text
);
}
#[test]
fn hover_self_shows_namespace_and_short_name_in_code_block() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace App;
class Foo {
public static function make(): self {
return new self();
}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 20).expect("expected hover on self");
let text = hover_text(&hover);
assert!(
text.contains("namespace App;"),
"should show namespace line: {}",
text
);
assert!(
text.contains("self = Foo"),
"should show self = short name in code block: {}",
text
);
assert!(
text.contains("<?php"),
"should contain <?php marker: {}",
text
);
}
#[test]
fn hover_parent_shows_fqn_in_header_and_code_block() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace App;
class Base {
public function hello(): string { return 'hi'; }
}
class Child extends Base {
public function hello(): string {
return parent::hello();
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 16).expect("expected hover on parent");
let text = hover_text(&hover);
assert!(text.contains("parent"), "should mention parent: {}", text);
assert!(
text.contains("<?php"),
"should contain <?php marker: {}",
text
);
}
#[test]
fn hover_this_shows_namespace_and_short_name_in_code_block() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace App;
class Widget {
public function run(): void {
$this->run();
}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 9).expect("expected hover on $this");
let text = hover_text(&hover);
assert!(
text.contains("namespace App;"),
"should show namespace line: {}",
text
);
assert!(
text.contains("$this = Widget"),
"should show $this = short name in code block: {}",
text
);
assert!(
text.contains("<?php"),
"should contain <?php marker: {}",
text
);
}
#[test]
fn hover_self_includes_class_docblock() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* A reusable widget.
*/
class Widget {
public static function make(): self {
return new self();
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 20).expect("expected hover on self");
let text = hover_text(&hover);
assert!(
text.contains("A reusable widget."),
"should include class docblock description: {}",
text
);
}
#[test]
fn hover_self_shows_deprecated_class() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @deprecated Use NewWidget instead.
*/
class OldWidget {
public static function make(): self {
return new self();
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 20).expect("expected hover on self");
let text = hover_text(&hover);
assert!(
text.contains("🪦 **deprecated** Use NewWidget instead."),
"should show deprecated with message: {}",
text
);
}
#[test]
fn hover_class_constant_shows_php_tag_and_const_syntax() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Config {
const APP_VERSION = '1.0.0';
}
class Usage {
public function run(): void {
echo Config::APP_VERSION;
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 24).expect("expected hover on APP_VERSION");
let text = hover_text(&hover);
assert!(
text.contains("<?php"),
"should contain <?php marker: {}",
text
);
assert!(
text.contains("const APP_VERSION = '1.0.0';"),
"should show const declaration with value: {}",
text
);
assert!(
text.contains("class Config {"),
"should show owning class wrapper: {}",
text
);
}
#[test]
fn hover_class_constant_shows_integer_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Limits {
const MAX_RETRIES = 3;
}
$x = Limits::MAX_RETRIES;
"#;
let hover = hover_at(&backend, uri, content, 4, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("const MAX_RETRIES = 3;"),
"should show integer value: {}",
text
);
}
#[test]
fn hover_class_constant_shows_array_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Config {
const ALLOWED = ['a', 'b', 'c'];
}
$x = Config::ALLOWED;
"#;
let hover = hover_at(&backend, uri, content, 4, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("const ALLOWED = ['a', 'b', 'c'];"),
"should show array value: {}",
text
);
}
#[test]
fn hover_typed_constant_shows_type_and_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Config {
const string APP_NAME = 'PHPantom';
}
$x = Config::APP_NAME;
"#;
let hover = hover_at(&backend, uri, content, 4, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("const APP_NAME: string = 'PHPantom';"),
"should show type hint and value: {}",
text
);
}
#[test]
fn hover_constant_via_self_shows_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Config {
const TIMEOUT = 30;
public function get(): int {
return self::TIMEOUT;
}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 22).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("const TIMEOUT = 30;"),
"should show value via self::: {}",
text
);
}
#[test]
fn hover_constant_expression_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Math {
const TWO_PI = 2 * 3.14159;
}
$x = Math::TWO_PI;
"#;
let hover = hover_at(&backend, uri, content, 4, 14).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("const TWO_PI = 2 * 3.14159;"),
"should show expression value: {}",
text
);
}
#[test]
fn hover_method_shows_native_param_types_in_code_block() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace App;
class User {
public string $email;
}
class UserRepo {
/**
* Find users by criteria.
* @param list<User> $criteria
* @return list<User>
*/
public function find(array $criteria): array {
return [];
}
public function run(): void {
$this->find([]);
}
}
"#;
let hover = hover_at(&backend, uri, content, 15, 16).expect("expected hover on find");
let text = hover_text(&hover);
assert!(
text.contains("function find(array $criteria)"),
"should show native param type 'array' in PHP code block: {}",
text
);
}
#[test]
fn hover_function_shows_native_param_types_in_code_block() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class User {
public string $name;
}
/**
* Process users.
* @param list<User> $users
*/
function processUsers(array $users): void {}
processUsers([]);
"#;
let hover = hover_at(&backend, uri, content, 9, 2).expect("expected hover on processUsers");
let text = hover_text(&hover);
assert!(
text.contains("function processUsers(array $users)"),
"should show native param type 'array' in PHP code block: {}",
text
);
}
#[test]
fn hover_unresolved_function_returns_none() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
unknownFunction();
"#;
backend.update_ast(uri, content);
let hover = backend.handle_hover(
uri,
content,
Position {
line: 1,
character: 5,
},
);
assert!(
hover.is_none(),
"hover on unknown function should return None"
);
}
#[test]
fn hover_unresolved_class_returns_none() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
new IAmNotReal();
"#;
backend.update_ast(uri, content);
let hover = backend.handle_hover(
uri,
content,
Position {
line: 1,
character: 6,
},
);
assert!(hover.is_none(), "hover on unknown class should return None");
}
#[test]
fn hover_function_shows_param_descriptions() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Process a batch of items.
* @param list<string> $items The items to process.
* @param bool $force Whether to force processing.
*/
function processBatch(array $items, bool $force = false): void {}
processBatch([]);
"#;
let hover = hover_at(&backend, uri, content, 7, 2).expect("expected hover on processBatch");
let text = hover_text(&hover);
assert!(
text.contains("**$items** `list<string>`"),
"should show param name and effective type: {}",
text
);
assert!(
text.contains("The items to process."),
"should show param description: {}",
text
);
}
#[test]
fn hover_method_shows_param_descriptions() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Processor {
/**
* Process a single item.
* @param list<int> $ids The IDs to process.
* @return bool
*/
public function process(array $ids): bool {
return true;
}
public function run(): void {
$this->process([]);
}
}
"#;
let hover = hover_at(&backend, uri, content, 11, 16).expect("expected hover on process");
let text = hover_text(&hover);
assert!(
text.contains("**$ids** `list<int>`"),
"should show param name and effective type for method: {}",
text
);
assert!(
text.contains("The IDs to process."),
"should show param description for method: {}",
text
);
}
#[test]
fn hover_param_not_shown_when_native_equals_effective_and_no_description() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Simple function.
* @param string $name
*/
function greet(string $name): void {}
greet('World');
"#;
let hover = hover_at(&backend, uri, content, 6, 2).expect("expected hover on greet");
let text = hover_text(&hover);
assert!(
!text.contains("@param"),
"should NOT show @param when native == effective and no description: {}",
text
);
}
#[test]
fn hover_param_shown_when_type_differs_but_no_description() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Takes a list.
* @param list<int> $items
*/
function sum(array $items): int { return 0; }
sum([]);
"#;
let hover = hover_at(&backend, uri, content, 6, 2).expect("expected hover on sum");
let text = hover_text(&hover);
assert!(
text.contains("**$items** `list<int>`"),
"should show param when effective type differs from native even without description: {}",
text
);
}
#[test]
fn hover_method_shows_return_description() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Repo {
/**
* Find all records.
* @return list<string> The matching records.
*/
public function findAll(): array {
return [];
}
public function run(): void {
$this->findAll();
}
}
"#;
let hover = hover_at(&backend, uri, content, 10, 16).expect("expected hover on findAll");
let text = hover_text(&hover);
assert!(
text.contains("**return** `list<string>`"),
"should show return type: {}",
text
);
assert!(
text.contains("The matching records."),
"should show return description: {}",
text
);
}
#[test]
fn hover_function_shows_return_description() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Get all names.
* @return list<string> All available names.
*/
function getNames(): array { return []; }
getNames();
"#;
let hover = hover_at(&backend, uri, content, 6, 2).expect("expected hover on getNames");
let text = hover_text(&hover);
assert!(
text.contains("**return** `list<string>`"),
"should show return type for standalone function: {}",
text
);
assert!(
text.contains("All available names."),
"should show return description for standalone function: {}",
text
);
}
#[test]
fn hover_function_shows_link_url() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Map over an array.
* @link https://php.net/manual/en/function.array-map.php
* @param callable $callback The callback.
* @return array The mapped array.
*/
function my_map(callable $callback, array $items): array { return []; }
my_map(fn($x) => $x, []);
"#;
let hover = hover_at(&backend, uri, content, 8, 2).expect("expected hover on my_map");
let text = hover_text(&hover);
assert!(
text.contains("https://php.net/manual/en/function.array-map.php"),
"should show @link URL in hover output: {}",
text
);
let url_pos = text
.find("https://php.net/manual/en/function.array-map.php")
.unwrap();
let code_pos = text.find("```php").unwrap();
assert!(
url_pos < code_pos,
"URL should appear before the code block: {}",
text
);
}
#[test]
fn hover_method_shows_link_url() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Helper {
/**
* Do something useful.
* @link https://example.com/docs
*/
public function doStuff(): void {}
public function run(): void {
$this->doStuff();
}
}
"#;
let hover = hover_at(&backend, uri, content, 8, 16).expect("expected hover on doStuff");
let text = hover_text(&hover);
assert!(
text.contains("https://example.com/docs"),
"should show @link URL for method hover: {}",
text
);
}
#[test]
fn hover_function_shows_combined_param_and_return_annotations() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Transform items.
* @link https://example.com/transform
* @param list<int> $items The input items.
* @param callable $fn The transform function.
* @return list<string> The transformed items.
*/
function transform(array $items, callable $fn): array { return []; }
transform([], fn($x) => (string)$x);
"#;
let hover = hover_at(&backend, uri, content, 9, 2).expect("expected hover on transform");
let text = hover_text(&hover);
assert!(
text.contains("Transform items."),
"should show description: {}",
text
);
assert!(
text.contains("https://example.com/transform"),
"should show link URL: {}",
text
);
assert!(
text.contains("**$items** `list<int>`"),
"should show param for items: {}",
text
);
assert!(
text.contains("The input items."),
"should show param description for items: {}",
text
);
assert!(
text.contains("**return** `list<string>`"),
"should show return type: {}",
text
);
assert!(
text.contains("The transformed items."),
"should show return description: {}",
text
);
assert!(
text.contains("function transform(array $items, callable $fn): array;"),
"should show native signature: {}",
text
);
}
#[test]
fn hover_param_shown_when_types_match_but_has_description() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Say hello.
* @param string $name The person's name to greet.
*/
function sayHello(string $name): void {}
sayHello('Alice');
"#;
let hover = hover_at(&backend, uri, content, 6, 2).expect("expected hover on sayHello");
let text = hover_text(&hover);
assert!(
text.contains("**$name** The person's name to greet."),
"should show param with description when types match: {}",
text
);
}
#[test]
fn hover_shows_docblock_param_and_return_when_types_match_native() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Applies the callback to the elements of the given arrays
* @link https://php.net/manual/en/function.array-map.php
* @param callable|null $callback Callback function to run for each element in each array.
* @param array $array An array to run through the callback function.
* @param array ...$arrays
* @return array an array containing all the elements of arr1
* after applying the callback function to each one.
*/
function array_map(?callable $callback, array $array, array ...$arrays): array {}
array_map(null, []);
"#;
let hover = hover_at(&backend, uri, content, 11, 2).expect("expected hover on array_map");
let text = hover_text(&hover);
assert!(
text.contains("Applies the callback to the elements of the given arrays"),
"should show description: {}",
text
);
assert!(
text.contains("https://php.net/manual/en/function.array-map.php"),
"should show link URL: {}",
text
);
assert!(
text.contains("**$callback** Callback function to run for each element in each array."),
"should show $callback with description (types match after nullable normalisation): {}",
text
);
assert!(
text.contains("**$array** An array to run through the callback function."),
"should show $array with description (types match): {}",
text
);
assert!(
!text.contains("**$arrays**"),
"should NOT show $arrays param entry (no description, types match): {}",
text
);
assert!(
text.contains("**return** an array containing all the elements of arr1 after applying the callback function to each one."),
"should show return with description (types match): {}",
text
);
assert!(
text.contains(
"function array_map(?callable $callback, array $array, array ...$arrays): array;"
),
"should show native signature in code block: {}",
text
);
}
#[test]
fn hover_shows_rich_callable_type_when_docblock_refines_native() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Applies the callback to the elements of the given arrays
* @param (callable(mixed $item): mixed)|null $callback Callback function to run for each element.
* @param array $array An array to run through the callback function.
* @return array the mapped array.
*/
function array_map(?callable $callback, array $array): array {}
array_map(null, []);
"#;
let hover = hover_at(&backend, uri, content, 8, 2).expect("expected hover on array_map");
let text = hover_text(&hover);
assert!(
text.contains("**$callback** `(callable(mixed): mixed)|null`"),
"should show $callback with rich effective type: {}",
text
);
assert!(
text.contains("Callback function to run for each element."),
"should show $callback description: {}",
text
);
}
#[test]
fn hover_property_suppresses_var_when_effective_is_fqn_of_native() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace Demo;
class Brush {
public string $color;
}
class Easel {
/** @var Brush */
public Brush $brush;
public function show(): void {
echo $this->brush;
}
}
"#;
let hover = hover_at(&backend, uri, content, 9, 22).expect("expected hover on brush");
let text = hover_text(&hover);
assert!(
!text.contains("@var"),
"should NOT show @var when effective type is just FQN of native type: {}",
text
);
assert!(
text.contains("public Brush $brush;"),
"should still show native type in code block: {}",
text
);
}
#[test]
fn hover_property_shows_var_when_effective_genuinely_differs() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace Demo;
class Pen {
public string $color;
}
class Drawer {
/** @var list<Pen> */
public array $pens;
public function show(): void {
echo $this->pens;
}
}
"#;
let hover = hover_at(&backend, uri, content, 9, 22).expect("expected hover on pens");
let text = hover_text(&hover);
assert!(
text.contains("@var list<Pen>"),
"should show @var with short names when effective type genuinely differs from native: {}",
text
);
}
#[test]
fn hover_property_suppresses_var_when_fqn_with_leading_backslash() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace App;
class Widget {}
class Factory {
/** @var \App\Widget */
public Widget $widget;
public function show(): void {
echo $this->widget;
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 22).expect("expected hover on widget");
let text = hover_text(&hover);
assert!(
!text.contains("@var"),
"should suppress @var for FQN with leading backslash: {}",
text
);
}
#[test]
fn hover_method_suppresses_return_annotation_when_fqn_matches_native() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace Demo;
class Item {}
class Store {
/** @return Item */
public function getItem(): Item { return new Item(); }
public function run(): void {
$this->getItem();
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 16).expect("expected hover on getItem");
let text = hover_text(&hover);
assert!(
!text.contains("**return**"),
"should suppress return annotation when FQN matches native: {}",
text
);
}
#[test]
fn hover_method_shows_return_annotation_when_types_genuinely_differ() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace Demo;
class Item {}
class Store {
/** @return list<Item> */
public function getItems(): array { return []; }
public function run(): void {
$this->getItems();
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 16).expect("expected hover on getItems");
let text = hover_text(&hover);
assert!(
text.contains("**return** `list<Item>`"),
"should show return annotation with short names when effective genuinely differs: {}",
text
);
}
#[test]
fn hover_new_class_shows_constructor() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Widget {
/**
* Create a new Widget.
*
* @param string $name The widget name
*/
public function __construct(string $name) {}
public function run(): void {}
}
function demo(): void {
$w = new Widget("hello");
}
"#;
let hover = hover_at(&backend, uri, content, 13, 15).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("__construct"),
"should show __construct method, got: {}",
text
);
assert!(
text.contains("string $name"),
"should show constructor params: {}",
text
);
assert!(
text.contains("Create a new Widget"),
"should show constructor description: {}",
text
);
}
#[test]
fn hover_new_class_shows_constructor_default_values() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Zoo {
public function __construct(
int $buffalo = 0,
string $name = 'default',
?array $items = null,
bool $active = true
) {}
}
function demo(): void {
$z = new Zoo();
}
"#;
let hover = hover_at(&backend, uri, content, 11, 15).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("__construct"),
"should show __construct: {}",
text
);
assert!(
text.contains("int $buffalo = 0"),
"should show int default value, got: {}",
text
);
assert!(
text.contains("string $name = 'default'"),
"should show string default value, got: {}",
text
);
assert!(
text.contains("?array $items = null"),
"should show null default value, got: {}",
text
);
assert!(
text.contains("bool $active = true"),
"should show bool default value, got: {}",
text
);
assert!(
!text.contains("= ..."),
"should not contain placeholder '= ...', got: {}",
text
);
}
#[test]
fn hover_method_shows_default_values() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Formatter {
public function format(string $text, int $indent = 4, string $sep = ', '): string {
return $text;
}
public function run(): void {
$this->format('hello');
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 16).expect("expected hover on format");
let text = hover_text(&hover);
assert!(
text.contains("int $indent = 4"),
"should show int default: {}",
text
);
assert!(
text.contains("string $sep = ', '"),
"should show string default: {}",
text
);
assert!(
!text.contains("= ..."),
"should not contain placeholder: {}",
text
);
}
#[test]
fn hover_method_shows_array_default_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Config {
public function load(array $options = []): void {}
public function run(): void {
$this->load();
}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 16).expect("expected hover on load");
let text = hover_text(&hover);
assert!(
text.contains("array $options = []"),
"should show empty array default: {}",
text
);
}
#[test]
fn hover_class_reference_without_new_shows_class() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Widget {
public function __construct(string $name) {}
}
function demo(Widget $w): void {}
"#;
let hover = hover_at(&backend, uri, content, 5, 17).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("class"),
"should show class kind, got: {}",
text
);
assert!(
!text.contains("__construct"),
"should NOT show __construct for a type-hint reference, got: {}",
text
);
}
#[test]
fn hover_new_class_without_constructor_shows_class() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class EmptyClass {}
function demo(): void {
$e = new EmptyClass();
}
"#;
let hover = hover_at(&backend, uri, content, 4, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("class"),
"should fall back to class hover when no __construct: {}",
text
);
assert!(
text.contains("EmptyClass"),
"should show class name: {}",
text
);
}
#[test]
fn hover_new_class_shows_inherited_constructor() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Base {
/** Build a base instance. */
public function __construct(int $id) {}
}
class Child extends Base {}
function demo(): void {
$c = new Child(42);
}
"#;
let hover = hover_at(&backend, uri, content, 8, 14).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("__construct"),
"should show inherited __construct: {}",
text
);
assert!(
text.contains("int $id"),
"should show inherited constructor params: {}",
text
);
}
#[test]
fn hover_static_method_context_shows_class_not_constructor() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Factory {
public function __construct(string $name) {}
public static function create(): self { return new self("x"); }
}
function demo(): void {
Factory::create();
}
"#;
let hover = hover_at(&backend, uri, content, 7, 5).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("class"),
"should show class hover for static access, got: {}",
text
);
assert!(
!text.contains("__construct"),
"should NOT show __construct for static access context, got: {}",
text
);
}
#[test]
fn hover_class_shows_template_params() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template TKey
* @template TValue
*/
class Collection {
/** @return TValue */
public function first(): mixed { return null; }
}
function test(Collection $c): void {}
"#;
let hover = hover_at(&backend, uri, content, 10, 17).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("**template** `TKey`"),
"should show TKey template param, got: {}",
text
);
assert!(
text.contains("**template** `TValue`"),
"should show TValue template param, got: {}",
text
);
}
#[test]
fn hover_class_shows_covariant_template_with_bound() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template TKey of array-key
* @template-covariant TValue of object
*/
class TypedMap {}
function test(TypedMap $m): void {}
"#;
let hover = hover_at(&backend, uri, content, 7, 17).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("**template** `TKey` of `array-key`"),
"should show TKey with bound, got: {}",
text
);
assert!(
text.contains("**template-covariant** `TValue` of `object`"),
"should show TValue as covariant with bound, got: {}",
text
);
}
#[test]
fn hover_class_shows_contravariant_template() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template-contravariant TInput
*/
class Consumer {}
function test(Consumer $c): void {}
"#;
let hover = hover_at(&backend, uri, content, 6, 17).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("**template-contravariant** `TInput`"),
"should show TInput as contravariant, got: {}",
text
);
}
#[test]
fn hover_interface_shows_template_params() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template TKey
* @template-covariant TValue
* @template-extends iterable<TKey, TValue>
*/
interface Traversable extends iterable {}
function test(Traversable $t): void {}
"#;
let hover = hover_at(&backend, uri, content, 8, 17).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("**template** `TKey`"),
"should show TKey template param, got: {}",
text
);
assert!(
text.contains("**template-covariant** `TValue`"),
"should show TValue as covariant, got: {}",
text
);
}
#[test]
fn hover_template_param_shows_covariant_variance() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template-covariant TValue
*/
class Box {
/** @return TValue */
public function get(): mixed { return null; }
}
"#;
let hover = hover_at(&backend, uri, content, 5, 19).expect("expected hover on TValue");
let text = hover_text(&hover);
assert!(
text.contains("**template-covariant**"),
"should show covariant variance, got: {}",
text
);
assert!(
text.contains("`TValue`"),
"should show the template name, got: {}",
text
);
}
#[test]
fn hover_template_param_in_callable_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template TKey of array-key
* @template TValue
*/
class Collection {
/**
* @param callable(TValue, TKey): mixed $callback
* @return static
*/
public function each(callable $callback): static { return $this; }
}
"#;
let hover = hover_at(&backend, uri, content, 7, 31).expect("expected hover on TKey");
let text = hover_text(&hover);
assert!(
text.contains("**template**"),
"should show template hover, got: {}",
text
);
assert!(
text.contains("`TKey`"),
"should show the template name, got: {}",
text
);
assert!(
text.contains("`array-key`"),
"should show the bound type, got: {}",
text
);
assert!(
!text.contains("class TKey"),
"should NOT show 'class TKey', got: {}",
text
);
}
#[test]
fn hover_template_param_without_bound() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template TValue
*/
class Box {
/** @return TValue */
public function get(): mixed { return null; }
}
"#;
let hover = hover_at(&backend, uri, content, 5, 19).expect("expected hover on TValue");
let text = hover_text(&hover);
assert!(
text.contains("**template**"),
"should show template hover, got: {}",
text
);
assert!(
text.contains("`TValue`"),
"should show the template name, got: {}",
text
);
assert!(
!text.contains(" of "),
"should NOT show 'of' when there is no bound, got: {}",
text
);
}
#[test]
fn hover_template_param_with_class_bound() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Animal {}
/**
* @template T of Animal
*/
class Zoo {
/** @return T */
public function first(): mixed { return null; }
}
"#;
let hover = hover_at(&backend, uri, content, 6, 16).expect("expected hover on T");
let text = hover_text(&hover);
assert!(
text.contains("**template**"),
"should show template hover, got: {}",
text
);
assert!(
text.contains("`T`"),
"should show the template name, got: {}",
text
);
assert!(
text.contains("`Animal`"),
"should show the bound class, got: {}",
text
);
}
#[test]
fn hover_method_level_template_param() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Util {
/**
* @template TItem of object
* @param TItem $item
* @return TItem
*/
public function identity(object $item): object { return $item; }
}
"#;
let hover = hover_at(&backend, uri, content, 4, 14).expect("expected hover on TItem");
let text = hover_text(&hover);
assert!(
text.contains("**template**"),
"should show template hover, got: {}",
text
);
assert!(
text.contains("`TItem`"),
"should show the template name, got: {}",
text
);
assert!(
text.contains("`object`"),
"should show the bound, got: {}",
text
);
}
#[test]
fn hover_fqn_class_in_docblock_resolves_stub() {
let backend = create_test_backend_with_stdclass_stub();
let uri = "file:///test.php";
let content = r#"<?php
namespace App\Models;
class Repo {
/** @return \stdClass */
public function find(): \stdClass { return new \stdClass(); }
}
"#;
let hover = hover_at(&backend, uri, content, 4, 19).expect("expected hover on \\stdClass");
let text = hover_text(&hover);
assert!(
text.contains("class stdClass"),
"should resolve stdClass from stubs, got: {}",
text
);
assert!(
!text.contains("class stdClass;"),
"should not show the unknown-class fallback (with semicolon), got: {}",
text
);
assert!(
text.contains("php.net"),
"should include the @link URL from the stub docblock, got: {}",
text
);
assert!(
text.contains("Created by typecasting to object"),
"should include the docblock description from the stub, got: {}",
text
);
}
#[test]
fn hover_fqn_class_in_generic_arg_resolves_stub() {
let backend = create_test_backend_with_stdclass_stub();
let uri = "file:///test.php";
let content = r#"<?php
namespace App\Models;
class Repo {
/** @return array<int, \stdClass> */
public function all(): array { return []; }
}
"#;
let hover = hover_at(&backend, uri, content, 4, 30).expect("expected hover on \\stdClass");
let text = hover_text(&hover);
assert!(
text.contains("class stdClass"),
"should resolve stdClass from stubs inside generic arg, got: {}",
text
);
assert!(
!text.contains("class stdClass;"),
"should not show the unknown-class fallback, got: {}",
text
);
}
#[test]
fn hover_class_with_link_tag() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Handles user authentication.
* @link https://example.com/docs/auth
*/
class AuthService {}
function demo(): void {
$a = new AuthService();
}
"#;
let hover = hover_at(&backend, uri, content, 8, 14).expect("expected hover on AuthService");
let text = hover_text(&hover);
assert!(
text.contains("class AuthService"),
"should show class name, got: {}",
text
);
assert!(
text.contains("Handles user authentication"),
"should show docblock description, got: {}",
text
);
assert!(
text.contains("https://example.com/docs/auth"),
"should show @link URL, got: {}",
text
);
}
#[test]
fn hover_function_shows_see_symbol_reference() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @param string $tz
*
* @deprecated So old, how old is it!
* @see UnsetDemo
* @see https://google.com/
*/
function formatUtfDate(string $tz): void {}
formatUtfDate('');
"#;
let hover = hover_at(&backend, uri, content, 10, 2).expect("expected hover on formatUtfDate");
let text = hover_text(&hover);
assert!(
text.contains("So old, how old is it!"),
"should show deprecation message, got: {}",
text
);
assert!(
!text.contains("(see:"),
"deprecation line should not contain inline @see, got: {}",
text
);
assert!(
text.contains("`UnsetDemo`"),
"should show @see symbol reference as inline code, got: {}",
text
);
assert!(
text.contains("[https://google.com/](https://google.com/)"),
"should show @see URL as clickable link, got: {}",
text
);
let see_pos = text.find("`UnsetDemo`").unwrap();
let code_pos = text.find("```php").unwrap();
assert!(
see_pos < code_pos,
"@see should appear before the code block: {}",
text
);
}
#[test]
fn hover_method_shows_see_reference() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Formatter {
/**
* Format a date.
* @see OtherFormatter::format()
*/
public function formatDate(): string { return ''; }
public function run(): void {
$this->formatDate();
}
}
"#;
let hover = hover_at(&backend, uri, content, 8, 16).expect("expected hover on formatDate");
let text = hover_text(&hover);
assert!(
text.contains("`OtherFormatter::format()`"),
"should show @see symbol for method hover, got: {}",
text
);
}
#[test]
fn hover_class_shows_see_reference() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* Old auth handler.
* @see NewAuthService
* @see https://docs.example.com/auth
*/
class OldAuth {}
function demo(): void {
$a = new OldAuth();
}
"#;
let hover = hover_at(&backend, uri, content, 9, 14).expect("expected hover on OldAuth");
let text = hover_text(&hover);
assert!(
text.contains("`NewAuthService`"),
"should show @see symbol for class hover, got: {}",
text
);
assert!(
text.contains("[https://docs.example.com/auth](https://docs.example.com/auth)"),
"should show @see URL for class hover, got: {}",
text
);
}
#[test]
fn hover_see_with_description_shows_description() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @see MyClass::newMethod() Use this instead.
*/
function oldFunc(): void {}
oldFunc();
"#;
let hover = hover_at(&backend, uri, content, 6, 2).expect("expected hover on oldFunc");
let text = hover_text(&hover);
assert!(
text.contains("`MyClass::newMethod()` Use this instead."),
"should show @see symbol with trailing description, got: {}",
text
);
}
#[test]
fn hover_see_url_not_duplicated_in_link_section() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @link https://php.net/manual/en/function.array-map.php
* @see https://example.com/docs
*/
function myFunc(): void {}
myFunc();
"#;
let hover = hover_at(&backend, uri, content, 7, 2).expect("expected hover on myFunc");
let text = hover_text(&hover);
assert!(
text.contains("[https://php.net/manual/en/function.array-map.php](https://php.net/manual/en/function.array-map.php)"),
"should show @link URL, got: {}",
text
);
assert!(
text.contains("[https://example.com/docs](https://example.com/docs)"),
"should show @see URL as plain link, got: {}",
text
);
let plain_link_count = text.matches("[https://example.com/docs]").count();
assert_eq!(
plain_link_count, 1,
"@see URL should appear exactly once, got {} occurrences in: {}",
plain_link_count, text
);
}
#[test]
fn hover_see_symbol_renders_clickable_file_link() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class UnsetDemo {
public function reset(): void {}
}
/**
* @param string $tz
*
* @deprecated So old, how old is it!
* @see UnsetDemo
*/
function formatUtfDate(string $tz): void {}
formatUtfDate('');
"#;
let hover = hover_at(&backend, uri, content, 13, 2).expect("expected hover on formatUtfDate");
let text = hover_text(&hover);
assert!(
text.contains("[`UnsetDemo`](file:///test.php#L2)"),
"should render @see symbol as clickable file link, got: {}",
text
);
}
#[test]
fn hover_see_class_member_renders_clickable_file_link() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class NewFormatter {
public function format(): string { return ''; }
}
/**
* @see NewFormatter::format()
*/
function oldFormat(): string { return ''; }
oldFormat();
"#;
let hover = hover_at(&backend, uri, content, 10, 2).expect("expected hover on oldFormat");
let text = hover_text(&hover);
assert!(
text.contains("[`NewFormatter::format()`](file:///test.php#L3)"),
"should render @see class::method as clickable file link, got: {}",
text
);
}
#[test]
fn hover_see_unresolvable_symbol_falls_back_to_inline_code() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @see NonExistentClass
*/
function myFunc(): void {}
myFunc();
"#;
let hover = hover_at(&backend, uri, content, 6, 2).expect("expected hover on myFunc");
let text = hover_text(&hover);
assert!(
text.contains("`NonExistentClass`"),
"unresolvable @see should render as inline code, got: {}",
text
);
assert!(
!text.contains("](file://"),
"unresolvable @see should not have a file link, got: {}",
text
);
}
#[test]
fn hover_see_url_deduplicated_when_same_as_link() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @param string $tz
*
* @deprecated So old, how old is it!
* @see http://google.com/
* @link http://google.com/
*/
function formatUtfDate(string $tz): void {}
formatUtfDate('');
"#;
let hover = hover_at(&backend, uri, content, 10, 2).expect("expected hover on formatUtfDate");
let text = hover_text(&hover);
assert!(
text.contains("[http://google.com/](http://google.com/)"),
"should show @link URL, got: {}",
text
);
let link_count = text
.matches("[http://google.com/](http://google.com/)")
.count();
assert_eq!(
link_count, 1,
"URL appearing in both @link and @see should render only once, got {} in: {}",
link_count, text
);
}
#[test]
fn hover_closure_in_parenthesized_callable_union() {
let backend = create_test_backend_with_closure_stub();
let uri = "file:///test.php";
let content = r#"<?php
class Builder {
/**
* @param (\Closure(static): mixed)|string|array $column
* @return $this
*/
public function where($column) {}
}
"#;
let hover = hover_at(&backend, uri, content, 3, 16).expect("expected hover on \\Closure");
let text = hover_text(&hover);
assert!(
text.contains("class Closure"),
"should show Closure class info, got: {}",
text
);
assert!(
!text.contains("(\\Closure"),
"should not include leading paren in class name, got: {}",
text
);
}
#[test]
fn hover_template_param_in_use_tag_generic_arg() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template TModel of \stdClass
*/
class Builder {
/** @use SomeTrait<TModel> */
use SomeTrait;
}
"#;
let hover = hover_at(&backend, uri, content, 5, 24).expect("expected hover on TModel");
let text = hover_text(&hover);
assert!(
text.contains("template") && text.contains("TModel"),
"should show template param info for TModel, got: {}",
text
);
}
#[test]
fn hover_static_in_docblock_generic_arg() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Model {
/** @return Builder<static> */
public static function query() {}
}
"#;
let hover = hover_at(&backend, uri, content, 2, 25).expect("expected hover on static");
let text = hover_text(&hover);
assert!(
text.contains("Model"),
"should resolve static to the enclosing class Model, got: {}",
text
);
}
#[test]
fn hover_backed_enum_case_shows_case_syntax_and_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
enum OrderStatus: string {
case Pending = 'pending';
case Processing = 'processing';
public function isPending(): bool { return $this === self::Pending; }
}
"#;
let hover = hover_at(&backend, uri, content, 5, 63).expect("expected hover on Pending");
let text = hover_text(&hover);
assert!(
text.contains("case Pending = 'pending';"),
"should show enum case syntax with value, got: {}",
text
);
assert!(
text.contains("enum OrderStatus: string"),
"should show enum keyword with backing type, got: {}",
text
);
assert!(
!text.contains("class "),
"should not show 'class' for an enum, got: {}",
text
);
assert!(
!text.contains("const "),
"should not show 'const' for an enum case, got: {}",
text
);
}
#[test]
fn hover_unit_enum_case_shows_case_syntax_without_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
enum Suit {
case Hearts;
case Diamonds;
public function isRed(): bool { return $this === self::Hearts; }
}
"#;
let hover = hover_at(&backend, uri, content, 5, 59).expect("expected hover on Hearts");
let text = hover_text(&hover);
assert!(
text.contains("case Hearts;"),
"should show enum case syntax without value, got: {}",
text
);
assert!(
text.contains("enum Suit"),
"should show enum keyword without backing type, got: {}",
text
);
assert!(
!text.contains("enum Suit:"),
"should not show colon for unit enum, got: {}",
text
);
assert!(
!text.contains("const "),
"should not show 'const' for a unit enum case, got: {}",
text
);
}
#[test]
fn hover_method_without_native_param_types_omits_docblock_types_from_code_block() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Builder {
/**
* @param (\Closure(static): mixed)|string|array $column
* @return $this
*/
public function where($column, $operator = null, $value = null, $boolean = 'and') {}
public function run(): void {
$this->where('active', true);
}
}
"#;
let hover = hover_at(&backend, uri, content, 9, 16).expect("expected hover on where");
let text = hover_text(&hover);
assert!(
text.contains("function where($column, $operator = null, $value = null, $boolean = 'and')"),
"should show untyped params in PHP code block, got: {}",
text
);
let code_block = text
.split("```php")
.nth(1)
.and_then(|s| s.split("```").next())
.unwrap_or("");
assert!(
!code_block.contains("Closure"),
"code block should not contain docblock Closure type, got code block: {}",
code_block
);
}
#[test]
fn hover_class_reference_in_property_default() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class FrostingCast {}
class Bread {
protected $casts = [
'icing' => FrostingCast::class,
];
}
"#;
let hover = hover_at(&backend, uri, content, 4, 20).expect("expected hover on FrostingCast");
let text = hover_text(&hover);
assert!(
text.contains("FrostingCast"),
"should show FrostingCast class info, got: {}",
text
);
}
#[test]
fn hover_class_in_multiline_docblock_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class SomeCollection {}
class Demo {
/**
* @return array<
* string,
* SomeCollection<int>
* >
*/
public function grouped() {}
public function run(): void {
$this->grouped();
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 10).expect("expected hover on SomeCollection");
let text = hover_text(&hover);
assert!(
text.contains("SomeCollection"),
"should show SomeCollection class info, got: {}",
text
);
}
#[test]
fn hover_template_param_in_multiline_docblock_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template TValue
*/
class FluentCollection {
/**
* @return array<
* string,
* FluentCollection<int, TValue>
* >
*/
public function grouped() {}
}
"#;
let hover = hover_at(&backend, uri, content, 8, 32).expect("expected hover on TValue");
let text = hover_text(&hover);
assert!(
text.contains("template") && text.contains("TValue"),
"should show template param info for TValue, got: {}",
text
);
}
#[test]
fn hover_anonymous_class_extends() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Animal {
public string $species;
}
function make() {
return new class extends Animal {};
}
"#;
let hover = hover_at(&backend, uri, content, 5, 31).expect("expected hover on Animal");
let text = hover_text(&hover);
assert!(
text.contains("Animal"),
"should show Animal class info, got: {}",
text
);
}
#[test]
fn hover_anonymous_class_implements() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
interface Runnable {
public function run(): void;
}
function make() {
return new class implements Runnable {
public function run(): void {}
};
}
"#;
let hover = hover_at(&backend, uri, content, 5, 34).expect("expected hover on Runnable");
let text = hover_text(&hover);
assert!(
text.contains("Runnable"),
"should show Runnable interface info, got: {}",
text
);
}
#[test]
fn hover_anonymous_class_method_param_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Widget {}
function make() {
return new class {
public function process(Widget $w): void {}
};
}
"#;
let hover = hover_at(&backend, uri, content, 4, 32).expect("expected hover on Widget");
let text = hover_text(&hover);
assert!(
text.contains("Widget"),
"should show Widget class info, got: {}",
text
);
}
#[test]
fn hover_class_in_top_level_const_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Handler {}
const DEFAULT_HANDLER = Handler::class;
"#;
let hover = hover_at(&backend, uri, content, 2, 24).expect("expected hover on Handler");
let text = hover_text(&hover);
assert!(
text.contains("Handler"),
"should show Handler class info, got: {}",
text
);
}
#[test]
fn hover_define_constant_shows_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
define('APP_VERSION', '1.0.0');
echo APP_VERSION;
"#;
backend.update_ast(uri, content);
let hover = hover_at(&backend, uri, content, 2, 7).expect("expected hover on APP_VERSION");
let text = hover_text(&hover);
assert!(
text.contains("'1.0.0'"),
"hover should show the constant value '1.0.0', got: {}",
text
);
assert!(
text.contains("APP_VERSION"),
"hover should show the constant name, got: {}",
text
);
}
#[test]
fn hover_define_constant_integer_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
define('MAX_RETRIES', 5);
echo MAX_RETRIES;
"#;
backend.update_ast(uri, content);
let hover = hover_at(&backend, uri, content, 2, 7).expect("expected hover on MAX_RETRIES");
let text = hover_text(&hover);
assert!(
text.contains("= 5"),
"hover should show 'const MAX_RETRIES = 5;', got: {}",
text
);
}
#[test]
fn hover_top_level_const_shows_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
const DB_HOST = 'localhost';
echo DB_HOST;
"#;
backend.update_ast(uri, content);
let hover = hover_at(&backend, uri, content, 2, 7).expect("expected hover on DB_HOST");
let text = hover_text(&hover);
assert!(
text.contains("'localhost'"),
"hover should show the constant value, got: {}",
text
);
assert!(
text.contains("DB_HOST"),
"hover should show the constant name, got: {}",
text
);
}
#[test]
fn hover_define_constant_no_value_still_works() {
let backend = create_test_backend();
let uri = "file:///test.php";
{
let mut dmap = backend.global_defines().write();
dmap.insert(
"LEGACY_CONST".to_string(),
phpantom_lsp::DefineInfo {
file_uri: "file:///legacy.php".to_string(),
name_offset: 0,
value: None,
},
);
}
let content = r#"<?php
echo LEGACY_CONST;
"#;
backend.update_ast(uri, content);
let hover = hover_at(&backend, uri, content, 1, 7).expect("expected hover on LEGACY_CONST");
let text = hover_text(&hover);
assert!(
text.contains("LEGACY_CONST"),
"hover should show the constant name, got: {}",
text
);
assert!(
!text.contains('='),
"hover should not show '=' when value is unknown, got: {}",
text
);
}
#[test]
fn hover_stub_constant_shows_value() {
let backend = create_test_backend_with_function_stubs();
let uri = "file:///test.php";
let content = r#"<?php
echo PHP_INT_MAX;
"#;
backend.update_ast(uri, content);
let hover = hover_at(&backend, uri, content, 1, 7).expect("expected hover on PHP_INT_MAX");
let text = hover_text(&hover);
assert!(
text.contains("PHP_INT_MAX"),
"hover should show the constant name, got: {}",
text
);
assert!(
text.contains('='),
"hover should show a value for the stub constant, got: {}",
text
);
}
#[test]
fn hover_stub_constant_php_eol_shows_value() {
let backend = create_test_backend_with_function_stubs();
let uri = "file:///test.php";
let content = r#"<?php
echo PHP_EOL;
"#;
backend.update_ast(uri, content);
let hover = hover_at(&backend, uri, content, 1, 7).expect("expected hover on PHP_EOL");
let text = hover_text(&hover);
assert!(
text.contains("PHP_EOL"),
"hover should show the constant name, got: {}",
text
);
}
#[test]
fn hover_variable_inside_isset() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Config {
public string $key;
}
function check(Config $cfg) {
isset($cfg->key);
}
"#;
let hover = hover_at(&backend, uri, content, 5, 16).expect("expected hover on key");
let text = hover_text(&hover);
assert!(
text.contains("key"),
"should show property info for key, got: {}",
text
);
}
#[test]
fn hover_variable_inside_empty() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Box {
public string $label;
}
function check(Box $b) {
empty($b->label);
}
"#;
let hover = hover_at(&backend, uri, content, 5, 15).expect("expected hover on label");
let text = hover_text(&hover);
assert!(
text.contains("label"),
"should show property info for label, got: {}",
text
);
}
#[test]
fn hover_variable_inside_interpolated_string() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Greeter {
public string $name;
}
function greet(Greeter $g) {
echo "Hello {$g->name}!";
}
"#;
let hover = hover_at(&backend, uri, content, 5, 22).expect("expected hover on name");
let text = hover_text(&hover);
assert!(
text.contains("name"),
"should show property info for name, got: {}",
text
);
}
#[test]
fn hover_first_class_callable_static_method() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Formatter {
public static function bold(string $text): string {
return "<b>$text</b>";
}
}
function test() {
$fn = Formatter::bold(...);
}
"#;
let hover = hover_at(&backend, uri, content, 7, 10).expect("expected hover on Formatter");
let text = hover_text(&hover);
assert!(
text.contains("Formatter"),
"should show Formatter class info, got: {}",
text
);
let hover2 = hover_at(&backend, uri, content, 7, 22).expect("expected hover on bold");
let text2 = hover_text(&hover2);
assert!(
text2.contains("bold"),
"should show bold method info, got: {}",
text2
);
}
#[test]
fn hover_first_class_callable_instance_method() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Printer {
public function printLine(string $line): void {}
}
function test(Printer $p) {
$fn = $p->printLine(...);
}
"#;
let hover = hover_at(&backend, uri, content, 5, 15).expect("expected hover on printLine");
let text = hover_text(&hover);
assert!(
text.contains("printLine"),
"should show printLine method info, got: {}",
text
);
}
#[test]
fn hover_method_override_shows_indicator() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Animal {
public function speak(): string { return ''; }
}
class Dog extends Animal {
public function speak(): string { return 'woof'; }
public function run(): void {
$this->speak();
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("overrides **Animal**"),
"should show override indicator, got: {}",
text
);
}
#[test]
fn hover_method_implements_shows_indicator() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
interface Loggable {
public function log(string $msg): void;
}
class FileLogger implements Loggable {
public function log(string $msg): void {}
public function run(): void {
$this->log('hi');
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("implements **Loggable**"),
"should show implements indicator, got: {}",
text
);
}
#[test]
fn hover_method_override_and_implements_shows_both() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
interface Renderable {
public function render(): string;
}
class BaseView {
public function render(): string { return ''; }
}
class HtmlView extends BaseView implements Renderable {
public function render(): string { return '<html>'; }
public function test(): void {
$this->render();
}
}
"#;
let hover = hover_at(&backend, uri, content, 10, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("overrides **BaseView**"),
"should show override indicator, got: {}",
text
);
assert!(
text.contains("implements **Renderable**"),
"should show implements indicator, got: {}",
text
);
}
#[test]
fn hover_virtual_method_shows_indicator() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @method string getName()
*/
class Magic {
public function test(): void {
$this->getName();
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("virtual"),
"should show virtual indicator, got: {}",
text
);
}
#[test]
fn hover_virtual_property_shows_indicator() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @property string $title
*/
class Document {
public function test(): void {
$this->title;
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("virtual"),
"should show virtual indicator for property, got: {}",
text
);
}
#[test]
fn hover_non_overriding_method_has_no_indicator() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Simple {
public function doStuff(): void {}
public function test(): void {
$this->doStuff();
}
}
"#;
let hover = hover_at(&backend, uri, content, 4, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
!text.contains("overrides"),
"should NOT show override indicator, got: {}",
text
);
assert!(
!text.contains("implements"),
"should NOT show implements indicator, got: {}",
text
);
assert!(
!text.contains("virtual"),
"should NOT show virtual indicator, got: {}",
text
);
}
#[test]
fn hover_constant_implements_interface_shows_indicator() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
interface HasVersion {
const VERSION = '1.0';
}
class App implements HasVersion {
const VERSION = '2.0';
public function test(): void {
self::VERSION;
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 15).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("implements **HasVersion**"),
"should show implements indicator for constant, got: {}",
text
);
}
#[test]
fn hover_property_override_shows_indicator() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Base {
public string $label = '';
}
class Child extends Base {
public string $label = 'child';
public function test(): void {
$this->label;
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("overrides **Base**"),
"should show override indicator for property, got: {}",
text
);
}
#[test]
fn hover_inherited_method_no_override_indicator() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class ParentClass {
public function inherited(): void {}
}
class ChildClass extends ParentClass {
public function test(): void {
$this->inherited();
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
!text.contains("overrides"),
"inherited method should NOT show override, got: {}",
text
);
}
#[test]
fn hover_cross_file_method_override_shows_indicator() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": { "App\\": "src/" }
}
}"#,
&[
(
"src/Base.php",
r#"<?php
namespace App;
class Base {
public function process(): void {}
}
"#,
),
(
"src/Child.php",
r#"<?php
namespace App;
class Child extends Base {
public function process(): void {}
public function test(): void {
$this->process();
}
}
"#,
),
],
);
let base_uri = format!("file://{}", _dir.path().join("src/Base.php").display());
let base_content = std::fs::read_to_string(_dir.path().join("src/Base.php")).unwrap();
backend.update_ast(&base_uri, &base_content);
let child_uri = format!("file://{}", _dir.path().join("src/Child.php").display());
let child_content = std::fs::read_to_string(_dir.path().join("src/Child.php")).unwrap();
let hover = hover_at(&backend, &child_uri, &child_content, 5, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("overrides **Base**"),
"cross-file override should show indicator, got: {}",
text
);
}
#[test]
fn hover_cross_file_method_implements_shows_indicator() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": { "App\\": "src/" }
}
}"#,
&[
(
"src/Loggable.php",
r#"<?php
namespace App;
interface Loggable {
public function log(string $msg): void;
}
"#,
),
(
"src/FileLogger.php",
r#"<?php
namespace App;
class FileLogger implements Loggable {
public function log(string $msg): void {}
public function test(): void {
$this->log('hi');
}
}
"#,
),
],
);
let iface_uri = format!("file://{}", _dir.path().join("src/Loggable.php").display());
let iface_content = std::fs::read_to_string(_dir.path().join("src/Loggable.php")).unwrap();
backend.update_ast(&iface_uri, &iface_content);
let impl_uri = format!(
"file://{}",
_dir.path().join("src/FileLogger.php").display()
);
let impl_content = std::fs::read_to_string(_dir.path().join("src/FileLogger.php")).unwrap();
let hover = hover_at(&backend, &impl_uri, &impl_content, 5, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("implements **Loggable**"),
"cross-file implements should show indicator, got: {}",
text
);
}
#[test]
fn hover_implements_indicator_same_file_with_namespace() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace Demo;
interface Renderable {
public function format(string $template): string;
}
abstract class Model {
abstract public function toArray(): array;
}
class HoverOriginsDemo extends Model implements Renderable {
public function format(string $template): string { return ''; }
public function toArray(): array { return []; }
public function demo(): void {
$this->format('x');
$this->toArray();
}
}
"#;
let hover = hover_at(&backend, uri, content, 15, 16).expect("expected hover on format");
let text = hover_text(&hover);
assert!(
text.contains("implements **Renderable**"),
"should show implements indicator for format(), got: {}",
text
);
let hover = hover_at(&backend, uri, content, 16, 16).expect("expected hover on toArray");
let text = hover_text(&hover);
assert!(
text.contains("overrides **Model**"),
"should show overrides indicator for toArray(), got: {}",
text
);
}
#[test]
fn hover_implements_indicator_multi_namespace_block_file() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace Demo {
interface Renderable {
public function format(string $template): string;
}
abstract class Model {
abstract public function toArray(): array;
public function getName(): string { return ''; }
}
class HoverOriginsDemo extends Model implements Renderable {
public function format(string $template): string { return ''; }
public function toArray(): array { return []; }
public function demo(): void {
$this->format('x');
$this->toArray();
$this->getName();
}
}
}
"#;
let hover = hover_at(&backend, uri, content, 16, 16).expect("expected hover on format");
let text = hover_text(&hover);
assert!(
text.contains("implements **Renderable**"),
"should show implements indicator for format(), got: {}",
text
);
let hover = hover_at(&backend, uri, content, 17, 16).expect("expected hover on toArray");
let text = hover_text(&hover);
assert!(
text.contains("overrides **Model**"),
"should show overrides indicator for toArray(), got: {}",
text
);
let hover = hover_at(&backend, uri, content, 18, 16).expect("expected hover on getName");
let text = hover_text(&hover);
assert!(
!text.contains("overrides"),
"inherited method should NOT show overrides, got: {}",
text
);
assert!(
!text.contains("implements"),
"inherited method should NOT show implements, got: {}",
text
);
}
#[test]
fn hover_enum_shows_cases_in_code_block() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
enum Color {
case Red;
case Green;
case Blue;
}
function paint(Color $c): void {}
"#;
let hover = hover_at(&backend, uri, content, 6, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("case Red;"),
"should list Red case, got: {}",
text
);
assert!(
text.contains("case Green;"),
"should list Green case, got: {}",
text
);
assert!(
text.contains("case Blue;"),
"should list Blue case, got: {}",
text
);
}
#[test]
fn hover_backed_enum_shows_cases_with_values() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
enum Status: string {
case Active = 'active';
case Inactive = 'inactive';
}
function check(Status $s): void {}
"#;
let hover = hover_at(&backend, uri, content, 5, 16).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("case Active = 'active';"),
"should list Active case with value, got: {}",
text
);
assert!(
text.contains("case Inactive = 'inactive';"),
"should list Inactive case with value, got: {}",
text
);
}
#[test]
fn hover_enum_does_not_show_regular_constants() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
enum Suit: string {
const TABLE = 'suits';
case Hearts = 'H';
case Diamonds = 'D';
}
function deal(Suit $s): void {}
"#;
let hover = hover_at(&backend, uri, content, 6, 14).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("case Hearts"),
"should list Hearts case, got: {}",
text
);
assert!(
!text.contains("const TABLE"),
"should NOT list regular constant TABLE in enum body, got: {}",
text
);
}
#[test]
fn hover_enum_with_no_cases_shows_plain_signature() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
enum Permission {
const ADMIN_ROLE = 'admin';
public function label(): string { return ''; }
}
function f(Permission $p): void {}
"#;
let hover = hover_at(&backend, uri, content, 5, 13).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("enum Permission"),
"should show enum signature, got: {}",
text
);
assert!(
!text.contains('{'),
"should not have curly brace body when no cases, got: {}",
text
);
}
#[test]
fn hover_trait_shows_public_method_signatures() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
trait Cacheable {
public function getCacheKey(): string { return ''; }
public static function flushCache(): void {}
protected function internalCache(): void {}
}
class Item {
use Cacheable;
}
"#;
let hover = hover_at(&backend, uri, content, 7, 9).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("function getCacheKey(): string;"),
"should show getCacheKey signature, got: {}",
text
);
assert!(
text.contains("static function flushCache(): void;"),
"should show static flushCache signature, got: {}",
text
);
assert!(
!text.contains("internalCache"),
"should NOT show protected method, got: {}",
text
);
}
#[test]
fn hover_trait_shows_public_properties() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
trait HasName {
public string $name;
protected int $id;
public function getName(): string { return $this->name; }
}
class Person {
use HasName;
}
"#;
let hover = hover_at(&backend, uri, content, 7, 9).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("public string $name;"),
"should show public property, got: {}",
text
);
assert!(
!text.contains("$id"),
"should NOT show protected property, got: {}",
text
);
assert!(
text.contains("function getName(): string;"),
"should show public method, got: {}",
text
);
}
#[test]
fn hover_trait_with_no_public_members_shows_plain_signature() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
trait Internal {
protected function secret(): void {}
}
class Box {
use Internal;
}
"#;
let hover = hover_at(&backend, uri, content, 5, 9).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("trait Internal"),
"should show trait signature, got: {}",
text
);
assert!(
!text.contains('{'),
"should not have curly brace body when no public members, got: {}",
text
);
}
#[test]
fn hover_class_does_not_show_member_body() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Widget {
public string $label;
public function render(): string { return ''; }
}
function f(Widget $w): void {}
"#;
let hover = hover_at(&backend, uri, content, 5, 12).expect("expected hover");
let text = hover_text(&hover);
assert!(
text.contains("class Widget"),
"should show class name, got: {}",
text
);
assert!(
!text.contains("$label"),
"should NOT list properties for a regular class, got: {}",
text
);
assert!(
!text.contains("function render"),
"should NOT list methods for a regular class, got: {}",
text
);
}
#[test]
fn hover_variable_namespace_from_type_not_file() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace Demo;
class Pencil { public function sketch(): void {} }
class Demo {
/**
* @param \Generator<int, Pencil> $pencils
*/
public function foreachGeneratorParam(\Generator $pencils): void
{
foreach ($pencils as $pencil) {
$pencil->sketch();
}
}
}
"#;
let hover =
hover_at(&backend, uri, content, 9, 18).expect("hover should be active on $pencils");
let text = hover_text(&hover);
assert!(
!text.contains("namespace Demo"),
"should not show file namespace for global Generator type, got: {}",
text
);
assert!(
text.contains("Generator<int, Pencil>"),
"should show full generic type, got: {}",
text
);
}
#[test]
fn hover_catch_variable_namespace_from_type_not_file() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace Demo;
class Demo {
public function risky(): void
{
try {
throw new \RuntimeException('oops');
} catch (\RuntimeException $e) {
echo $e->getMessage();
}
}
}
"#;
let hover =
hover_at(&backend, uri, content, 7, 36).expect("hover should be active on catch $e");
let text = hover_text(&hover);
assert!(
!text.contains("namespace Demo"),
"should not show file namespace for global RuntimeException, got: {}",
text
);
assert!(
text.contains("RuntimeException"),
"should show RuntimeException, got: {}",
text
);
}
#[test]
fn hover_variable_namespaced_type_shows_type_namespace() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace App\Services;
class MyService {
/**
* @param \App\Models\User $user
*/
public function process($user): void
{
echo $user;
}
}
"#;
let hover = hover_at(&backend, uri, content, 8, 14).expect("hover should be active on $user");
let text = hover_text(&hover);
assert!(
text.contains("namespace App\\Models"),
"should show the type's namespace, got: {}",
text
);
assert!(
!text.contains("namespace App\\Services"),
"should not show the file's namespace, got: {}",
text
);
}
#[test]
fn hover_variable_with_template_type_shows_template_info() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template-covariant TNode of AstNode
*/
class NodeList {
/**
* @param TNode $node
*/
public function add($node): void {
echo $node;
}
}
class AstNode {}
"#;
let hover = hover_at(&backend, uri, content, 9, 14).expect("hover should be active on $node");
let text = hover_text(&hover);
assert!(
text.contains("template-covariant"),
"should show template variance, got: {}",
text
);
assert!(
text.contains("TNode"),
"should show template name, got: {}",
text
);
assert!(
text.contains("AstNode"),
"should show template bound, got: {}",
text
);
assert!(
text.contains("$node = TNode"),
"should still show type assignment, got: {}",
text
);
}
#[test]
fn hover_variable_with_unbounded_template_shows_variance() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template T
*/
class Box {
/**
* @param T $value
*/
public function set($value): void {
echo $value;
}
}
"#;
let hover = hover_at(&backend, uri, content, 9, 14).expect("hover should be active on $value");
let text = hover_text(&hover);
assert!(
text.contains("template"),
"should show template variance, got: {}",
text
);
assert!(
text.contains("`T`"),
"should show template name, got: {}",
text
);
assert!(
text.contains("$value = T"),
"should still show type assignment, got: {}",
text
);
}
#[test]
fn hover_property_with_template_type_shows_template_info() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template-covariant TNode of AstNode
*/
class NodeList {
/** @var TNode */
public $node;
public function demo(): void {
$this->node;
}
}
class AstNode {
public function getChildren(): array { return []; }
}
"#;
let hover = hover_at(&backend, uri, content, 9, 16).expect("expected hover on node property");
let text = hover_text(&hover);
assert!(
text.contains("template-covariant"),
"should show template variance, got: {}",
text
);
assert!(
text.contains("TNode"),
"should show template name, got: {}",
text
);
assert!(
text.contains("AstNode"),
"should show template bound, got: {}",
text
);
}
#[test]
fn hover_property_with_unbounded_template_shows_variance() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template T
*/
class Box {
/** @var T */
public $value;
public function demo(): void {
$this->value;
}
}
"#;
let hover = hover_at(&backend, uri, content, 9, 16).expect("expected hover on value property");
let text = hover_text(&hover);
assert!(
text.contains("template"),
"should show template variance, got: {}",
text
);
assert!(
text.contains("`T`"),
"should show template name, got: {}",
text
);
}
#[test]
fn hover_method_with_template_return_type_shows_template_info() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template-covariant TNode of AstNode
*/
class NodeList {
/**
* @return TNode
*/
public function first() {}
public function demo(): void {
$this->first();
}
}
class AstNode {}
"#;
let hover = hover_at(&backend, uri, content, 11, 16).expect("expected hover on first()");
let text = hover_text(&hover);
assert!(
text.contains("template-covariant"),
"should show template variance, got: {}",
text
);
assert!(
text.contains("TNode"),
"should show template name, got: {}",
text
);
assert!(
text.contains("AstNode"),
"should show template bound, got: {}",
text
);
}
#[test]
fn hover_method_with_template_param_type_shows_template_info() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template T
* @template-covariant TNode of AstNode
*/
class NodeList {
/**
* @param TNode $node
* @param T $extra
* @return TNode
*/
public function add($node, $extra) {}
public function demo(): void {
$this->add(null, null);
}
}
class AstNode {}
"#;
let hover = hover_at(&backend, uri, content, 14, 15).expect("expected hover on add()");
let text = hover_text(&hover);
assert!(
text.contains("template-covariant"),
"should show covariant variance for TNode, got: {}",
text
);
assert!(
text.contains("`T`"),
"should show template T info, got: {}",
text
);
let count = text.matches("template-covariant").count();
assert_eq!(
count, 1,
"TNode template info should appear exactly once, got {} in: {}",
count, text
);
}
#[test]
fn hover_var_annotation_does_not_leak_across_method_scopes() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class ObjectShapeDemo {
public function demo(): void {
/** @var object{title: string, score: float} $item */
$item = getUnknownValue();
$item->title;
}
}
class ObjectMapper {
/**
* @template T
* @param T $item
* @return T
*/
public function identity(mixed $item): mixed {
return $item;
}
}
"#;
let hover = hover_at(&backend, uri, content, 16, 16).expect("expected hover on $item");
let text = hover_text(&hover);
assert!(
!text.contains("object{"),
"should NOT leak @var from ObjectShapeDemo into ObjectMapper, got: {}",
text
);
assert!(
text.contains("$item = T"),
"should resolve $item to template param T, got: {}",
text
);
}
#[test]
fn hover_var_annotation_within_same_method_still_works() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Demo {
public function run(): void {
/** @var object{name: string} $thing */
$thing = getUnknown();
echo $thing;
}
}
"#;
let hover = hover_at(&backend, uri, content, 5, 14).expect("expected hover on $thing");
let text = hover_text(&hover);
assert!(
text.contains("object{name: string}"),
"should still resolve @var in the same method, got: {}",
text
);
}
#[test]
fn hover_method_level_template_in_return_shows_info() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class ObjectMapper {
/**
* @template T
* @param T $item
* @return T
*/
public function identity(mixed $item): mixed {
return $item;
}
public function demo(): void {
$this->identity(null);
}
}
"#;
let hover = hover_at(&backend, uri, content, 12, 16).expect("expected hover on identity()");
let text = hover_text(&hover);
assert!(
text.contains("**template** `T`"),
"should show method-level template info for T, got: {}",
text
);
}
#[test]
fn hover_method_level_template_with_bound_shows_info() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Model {}
class Repo {
/**
* @template T of Model
* @param class-string<T> $class
* @return T
*/
public function find(string $class): mixed {
return new $class();
}
public function demo(): void {
$this->find(Model::class);
}
}
"#;
let hover = hover_at(&backend, uri, content, 13, 16).expect("expected hover on find()");
let text = hover_text(&hover);
assert!(
text.contains("**template** `T` of `Model`"),
"should show method-level template with bound, got: {}",
text
);
}
#[test]
fn hover_method_level_template_takes_priority_over_class_level() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Animal {}
class Plant {}
/**
* @template T of Animal
*/
class Container {
/**
* @template T of Plant
* @param T $item
* @return T
*/
public function wrap($item) {}
public function demo(): void {
$this->wrap(null);
}
}
"#;
let hover = hover_at(&backend, uri, content, 15, 16).expect("expected hover on wrap()");
let text = hover_text(&hover);
assert!(
text.contains("of `Plant`"),
"should show method-level bound (Plant), not class-level (Animal), got: {}",
text
);
assert!(
!text.contains("of `Animal`"),
"should NOT show class-level bound, got: {}",
text
);
}
#[test]
fn hover_class_level_template_still_shown_when_method_has_none() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template-covariant TValue of object
*/
class TypedBox {
/**
* @return TValue
*/
public function get() {}
public function demo(): void {
$this->get();
}
}
"#;
let hover = hover_at(&backend, uri, content, 11, 16).expect("expected hover on get()");
let text = hover_text(&hover);
assert!(
text.contains("**template-covariant** `TValue` of `object`"),
"should show class-level template info when method has no own templates, got: {}",
text
);
}
const HOVER_LARAVEL_COMPOSER: &str = r#"{
"autoload": {
"psr-4": {
"App\\": "src/",
"Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Eloquent/",
"Illuminate\\Database\\Query\\": "vendor/illuminate/Query/",
"Illuminate\\Database\\Concerns\\": "vendor/illuminate/Concerns/"
}
}
}"#;
const HOVER_MODEL_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent;
class Model {
/** @return \\Illuminate\\Database\\Eloquent\\Builder<static> */
public static function with(mixed $relations): Builder { return new Builder(); }
}
";
const HOVER_BUILDER_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent;
use Illuminate\\Database\\Concerns\\BuildsQueries;
/**
* @template TModel of \\Illuminate\\Database\\Eloquent\\Model
* @mixin \\Illuminate\\Database\\Query\\Builder
*/
class Builder {
/** @use BuildsQueries<TModel> */
use BuildsQueries;
/** @return static */
public function where(string $column, mixed $operator = null, mixed $value = null): static { return $this; }
/** @return static */
public function orderBy(string $column, string $direction = 'asc'): static { return $this; }
/** @return \\Illuminate\\Database\\Eloquent\\Collection<int, TModel> */
public function get(): Collection { return new Collection(); }
/** @return static */
public function limit(int $value): static { return $this; }
}
";
const HOVER_QUERY_BUILDER_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Query;
class Builder {
/** @return static */
public function whereIn(string $column, array $values): static { return $this; }
/** @return static */
public function groupBy(string ...$groups): static { return $this; }
}
";
const HOVER_BUILDS_QUERIES_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Concerns;
/**
* @template TValue
*/
trait BuildsQueries {
/** @return TValue|null */
public function first(): mixed { return null; }
}
";
const HOVER_COLLECTION_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent;
/**
* @template TKey of array-key
* @template TModel
*/
class Collection {
/** @return TModel|null */
public function first(): mixed { return null; }
}
";
fn make_laravel_hover_workspace(app_files: &[(&str, &str)]) -> (Backend, tempfile::TempDir) {
let mut files: Vec<(&str, &str)> = vec![
("vendor/illuminate/Eloquent/Model.php", HOVER_MODEL_PHP),
("vendor/illuminate/Eloquent/Builder.php", HOVER_BUILDER_PHP),
(
"vendor/illuminate/Eloquent/Collection.php",
HOVER_COLLECTION_PHP,
),
(
"vendor/illuminate/Query/Builder.php",
HOVER_QUERY_BUILDER_PHP,
),
(
"vendor/illuminate/Concerns/BuildsQueries.php",
HOVER_BUILDS_QUERIES_PHP,
),
];
files.extend_from_slice(app_files);
create_psr4_workspace(HOVER_LARAVEL_COMPOSER, &files)
}
#[test]
fn hover_scope_method_on_builder_variable() {
let brand_php = "\
<?php
namespace App;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Brand extends Model {
public function scopeActive(Builder $query): void {}
public function test() {
$query = Brand::where('genre', 'fiction');
$query->active();
$query->orderBy('name')->get();
}
}
";
let (backend, _dir) = make_laravel_hover_workspace(&[("src/Brand.php", brand_php)]);
let uri = format!("file://{}", _dir.path().join("src/Brand.php").display());
backend.update_ast(&uri, brand_php);
let hover = hover_at(&backend, &uri, brand_php, 8, 17);
assert!(
hover.is_some(),
"hover should be shown on scope method active() called on $query (Builder variable)"
);
let text = hover_text(hover.as_ref().unwrap());
assert!(
text.contains("active"),
"hover should mention active, got: {}",
text
);
let hover_ob = hover_at(&backend, &uri, brand_php, 9, 18);
assert!(
hover_ob.is_some(),
"hover should be shown on orderBy() called on $query (Builder variable)"
);
let hover_get = hover_at(&backend, &uri, brand_php, 9, 36);
assert!(
hover_get.is_some(),
"hover should be shown on get() chained after $query->orderBy()"
);
}
#[test]
fn hover_scope_method_after_inline_builder_chain() {
let brand_php = "\
<?php
namespace App;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Brand extends Model {
public function scopeActive(Builder $query): void {}
public function scopeOfGenre(Builder $query, string $genre): void {}
public function test() {
Brand::where('active', 1)->active()->ofGenre('sci-fi')->get();
}
}
";
let (backend, _dir) = make_laravel_hover_workspace(&[("src/Brand.php", brand_php)]);
let uri = format!("file://{}", _dir.path().join("src/Brand.php").display());
backend.update_ast(&uri, brand_php);
let h_where = hover_at(&backend, &uri, brand_php, 8, 16);
assert!(
h_where.is_some(),
"hover should work on where() in Brand::where()"
);
let h_active = hover_at(&backend, &uri, brand_php, 8, 36);
assert!(
h_active.is_some(),
"hover should work on scope method active() after Brand::where() chain"
);
let h_genre = hover_at(&backend, &uri, brand_php, 8, 47);
assert!(
h_genre.is_some(),
"hover should work on scope method ofGenre() after chained scope"
);
}
#[test]
fn hover_scope_method_multi_namespace_single_file() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace Illuminate\Database\Eloquent {
abstract class Model {
/** @return \Illuminate\Database\Eloquent\Builder<static> */
public static function query() {}
}
/**
* @template TModel of \Illuminate\Database\Eloquent\Model
*
* @mixin \Illuminate\Database\Query\Builder
*/
class Builder {
/**
* @param (\Closure(static): mixed)|string|array $column
* @return $this
*/
public function where($column, $operator = null, $value = null, $boolean = 'and') {}
/** @return \Illuminate\Database\Eloquent\Collection<int, TModel> */
public function get($columns = ['*']) { return new Collection(); }
}
/**
* @template TKey of array-key
* @template TModel of \Illuminate\Database\Eloquent\Model
*/
class Collection {}
}
namespace Illuminate\Database\Query {
class Builder {
/** @return $this */
public function orderBy($column, $direction = 'asc') { return $this; }
/** @return $this */
public function limit($value) { return $this; }
}
}
namespace Demo {
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
class BlogAuthor extends Model {
public function scopeActive(Builder $query): void {}
public function scopeOfGenre(Builder $query, string $genre): void {}
}
class EloquentDemo {
public function run(): void {
$author = new BlogAuthor();
$author->active();
BlogAuthor::active();
BlogAuthor::where('active', 1)->active()->ofGenre('sci-fi')->get();
$query = BlogAuthor::where('genre', 'fiction');
$query->active();
$query->orderBy('name')->get();
}
}
}
"#;
let author_active_line = content
.lines()
.enumerate()
.find(|(_, l)| l.contains("$author->active()"))
.map(|(i, _)| i as u32)
.expect("should find $author->active() line");
let static_active_line = content
.lines()
.enumerate()
.find(|(_, l)| l.contains("BlogAuthor::active()"))
.map(|(i, _)| i as u32)
.expect("should find BlogAuthor::active() line");
let inline_chain_line = content
.lines()
.enumerate()
.find(|(_, l)| l.contains("BlogAuthor::where('active', 1)->active"))
.map(|(i, _)| i as u32)
.expect("should find inline chain line");
let query_active_line = content
.lines()
.enumerate()
.find(|(_, l)| l.trim() == "$query->active();")
.map(|(i, _)| i as u32)
.expect("should find $query->active() line");
let query_orderby_line = content
.lines()
.enumerate()
.find(|(_, l)| l.contains("$query->orderBy"))
.map(|(i, _)| i as u32)
.expect("should find $query->orderBy line");
let h_instance_scope = hover_at(&backend, uri, content, author_active_line, 23);
assert!(
h_instance_scope.is_some(),
"hover should work on $author->active() (scope on model instance)"
);
let h_static_scope = hover_at(&backend, uri, content, static_active_line, 25);
assert!(
h_static_scope.is_some(),
"hover should work on BlogAuthor::active() (scope as static)"
);
let h_where = hover_at(&backend, uri, content, inline_chain_line, 26);
assert!(
h_where.is_some(),
"hover should work on where() in BlogAuthor::where() (builder-forwarded)"
);
let h_var_scope = hover_at(&backend, uri, content, query_active_line, 21);
assert!(
h_var_scope.is_some(),
"hover should work on $query->active() (scope on Builder variable)"
);
let h_order_by = hover_at(&backend, uri, content, query_orderby_line, 22);
assert!(
h_order_by.is_some(),
"hover should work on $query->orderBy() (Builder method on variable)"
);
}
#[test]
fn hover_scope_on_builder_in_example_php() {
let backend = create_test_backend();
let example_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("example.php");
let content = std::fs::read_to_string(&example_path).expect("failed to read example.php");
let uri = format!("file://{}", example_path.display());
backend.update_ast(&uri, &content);
let lines: Vec<&str> = content.lines().collect();
let author_active_idx = lines
.iter()
.enumerate()
.find(|(_, l)| l.contains("$author->active()"))
.map(|(i, _)| i)
.expect("should find $author->active() in example.php");
let author_active_line = lines[author_active_idx];
let author_active_col = author_active_line
.find("active()")
.expect("should find active() token") as u32;
let h = hover_at(
&backend,
&uri,
&content,
author_active_idx as u32,
author_active_col + 1,
);
assert!(
h.is_some(),
"hover should work on $author->active() in example.php (line {})",
author_active_idx
);
let static_active_idx = lines
.iter()
.enumerate()
.find(|(_, l)| l.trim() == "BlogAuthor::active();")
.map(|(i, _)| i)
.expect("should find BlogAuthor::active() in example.php");
let static_active_line = lines[static_active_idx];
let static_active_col = static_active_line
.find("active()")
.expect("should find active() token") as u32;
let h2 = hover_at(
&backend,
&uri,
&content,
static_active_idx as u32,
static_active_col + 1,
);
assert!(
h2.is_some(),
"hover should work on BlogAuthor::active() in example.php (line {})",
static_active_idx
);
let chain_idx = lines
.iter()
.enumerate()
.find(|(_, l)| l.contains("BlogAuthor::where('active', 1)->active()"))
.map(|(i, _)| i)
.expect("should find inline chain line in example.php");
let chain_line = lines[chain_idx];
let where_col = chain_line.find("where(").expect("should find where( token") as u32;
let h_where = hover_at(&backend, &uri, &content, chain_idx as u32, where_col + 1);
assert!(
h_where.is_some(),
"hover should work on where() in BlogAuthor::where() in example.php (line {})",
chain_idx
);
let after_arrow = chain_line
.find(")->active()")
.expect("should find )->active()")
+ 2; let active_col = after_arrow as u32;
let h_active = hover_at(&backend, &uri, &content, chain_idx as u32, active_col + 1);
assert!(
h_active.is_some(),
"hover should work on active() after where() chain in example.php (line {})",
chain_idx
);
let of_genre_col = chain_line
.find("ofGenre(")
.expect("should find ofGenre( token") as u32;
let h_genre = hover_at(&backend, &uri, &content, chain_idx as u32, of_genre_col + 1);
assert!(
h_genre.is_some(),
"hover should work on ofGenre() after chained scope in example.php (line {})",
chain_idx
);
let query_active_idx = lines
.iter()
.enumerate()
.find(|(_, l)| l.trim() == "$query->active();")
.map(|(i, _)| i)
.expect("should find $query->active() in example.php");
let query_active_line = lines[query_active_idx];
let query_active_col = query_active_line
.find("active()")
.expect("should find active() token") as u32;
let h_qactive = hover_at(
&backend,
&uri,
&content,
query_active_idx as u32,
query_active_col + 1,
);
assert!(
h_qactive.is_some(),
"hover should work on $query->active() in example.php (line {})",
query_active_idx
);
let query_orderby_idx = lines
.iter()
.enumerate()
.find(|(_, l)| l.contains("$query->orderBy('name')->get()"))
.map(|(i, _)| i)
.expect("should find $query->orderBy line in example.php");
let query_orderby_line = lines[query_orderby_idx];
let orderby_col = query_orderby_line
.find("orderBy(")
.expect("should find orderBy( token") as u32;
let h_orderby = hover_at(
&backend,
&uri,
&content,
query_orderby_idx as u32,
orderby_col + 1,
);
assert!(
h_orderby.is_some(),
"hover should work on $query->orderBy() in example.php (line {})",
query_orderby_idx
);
}
#[test]
fn hover_scope_on_builder_in_example_php_no_reparse() {
let backend = create_test_backend();
let example_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("example.php");
let content = std::fs::read_to_string(&example_path).expect("failed to read example.php");
let uri = format!("file://{}", example_path.display());
backend.update_ast(&uri, &content);
let lines: Vec<&str> = content.lines().collect();
let find = |pattern: &str, token: &str| -> (u32, u32) {
let idx = lines
.iter()
.enumerate()
.find(|(_, l)| l.contains(pattern))
.map(|(i, _)| i)
.unwrap_or_else(|| panic!("should find {:?} in example.php", pattern));
let col = lines[idx]
.find(token)
.unwrap_or_else(|| panic!("should find {:?} on line {}", token, idx));
(idx as u32, col as u32 + 1)
};
let (line, col) = find("$author->active()", "active()");
let h1 = backend.handle_hover(
&uri,
&content,
Position {
line,
character: col,
},
);
assert!(
h1.is_some(),
"hover should work on $author->active() (line {})",
line
);
let (line, col) = find("BlogAuthor::active();", "active();");
let h2 = backend.handle_hover(
&uri,
&content,
Position {
line,
character: col,
},
);
assert!(
h2.is_some(),
"hover should work on BlogAuthor::active() (line {})",
line
);
let (line, _) = find(
"BlogAuthor::where('active', 1)->active()->ofGenre",
"where(",
);
let where_col = lines[line as usize].find("where(").unwrap() as u32 + 1;
let h3 = backend.handle_hover(
&uri,
&content,
Position {
line,
character: where_col,
},
);
assert!(
h3.is_some(),
"hover should work on BlogAuthor::where() (line {})",
line
);
let chain_line = line;
let active_pos = lines[chain_line as usize]
.find(")->active()")
.expect("should find )->active()") as u32
+ 2; let h4 = backend.handle_hover(
&uri,
&content,
Position {
line: chain_line,
character: active_pos + 1,
},
);
assert!(
h4.is_some(),
"hover should work on active() after where() chain (line {}, col {})",
chain_line,
active_pos + 1
);
let of_genre_col = lines[chain_line as usize]
.find("ofGenre(")
.expect("should find ofGenre(") as u32
+ 1;
let h5 = backend.handle_hover(
&uri,
&content,
Position {
line: chain_line,
character: of_genre_col,
},
);
assert!(
h5.is_some(),
"hover should work on ofGenre() in chain (line {}, col {})",
chain_line,
of_genre_col
);
let (line, col) = find("$query->active();", "active();");
let h6 = backend.handle_hover(
&uri,
&content,
Position {
line,
character: col,
},
);
assert!(
h6.is_some(),
"hover should work on $query->active() (line {})",
line
);
let (line, col) = find("$query->orderBy('name')->get()", "orderBy(");
let h7 = backend.handle_hover(
&uri,
&content,
Position {
line,
character: col,
},
);
assert!(
h7.is_some(),
"hover should work on $query->orderBy() (line {})",
line
);
}
#[test]
fn hover_scope_survives_builder_cache_poisoning() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
namespace Illuminate\Database\Eloquent {
abstract class Model {
/** @return \Illuminate\Database\Eloquent\Builder<static> */
public static function query() {}
}
/**
* @template TModel of \Illuminate\Database\Eloquent\Model
* @mixin \Illuminate\Database\Query\Builder
*/
class Builder {
/** @return $this */
public function where($column, $operator = null, $value = null) {}
/** @return \Illuminate\Database\Eloquent\Collection<int, TModel> */
public function get($columns = ['*']) { return new Collection(); }
/** @return static */
public function orderBy(string $column, string $direction = 'asc'): static { return $this; }
}
/** @template TKey @template TModel */
class Collection {}
}
namespace Illuminate\Database\Query {
class Builder {
/** @return $this */
public function limit(int $value) { return $this; }
}
}
namespace App {
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
// Model with NO scope methods — hovering on its Builder chain
// populates the cache with a plain Builder entry.
class PlainModel extends Model {}
// Model WITH scope methods.
class ScopedModel extends Model {
public function scopeFeatured(Builder $query): void {}
public function scopeRecent(Builder $query): void {}
}
class Demo {
public function run(): void {
// Step 1: hover on get() here populates the Builder cache
// with a plain Builder (no scope methods from PlainModel).
PlainModel::where('id', 1)->get();
// Step 2: hover on featured() here must still work even
// though the Builder cache was seeded without scopes.
ScopedModel::where('active', 1)->featured()->recent()->get();
// Also test $variable path.
$q = ScopedModel::where('status', 'draft');
$q->featured();
}
}
}
"#;
backend.update_ast(uri, content);
let lines: Vec<&str> = content.lines().collect();
let find = |pattern: &str, token: &str| -> (u32, u32) {
let idx = lines
.iter()
.enumerate()
.find(|(_, l)| l.contains(pattern))
.map(|(i, _)| i)
.unwrap_or_else(|| panic!("should find {:?}", pattern));
let col = lines[idx]
.find(token)
.unwrap_or_else(|| panic!("should find token {:?} on line {:?}", token, lines[idx]))
as u32;
(idx as u32, col + 1)
};
let (line, col) = find("PlainModel::where('id', 1)->get()", "get()");
let h_get = backend.handle_hover(
uri,
content,
Position {
line,
character: col,
},
);
assert!(
h_get.is_some(),
"hover should work on get() after PlainModel::where() (line {})",
line
);
let (line, col) = find("ScopedModel::where('active', 1)->featured()", "featured()");
let h_featured = backend.handle_hover(
uri,
content,
Position {
line,
character: col,
},
);
assert!(
h_featured.is_some(),
"hover should work on featured() after ScopedModel::where() even when Builder cache was seeded by PlainModel (line {})",
line
);
let text = hover_text(h_featured.as_ref().unwrap());
assert!(
text.contains("featured"),
"hover text should mention featured, got: {}",
text
);
let chain_line = lines
.iter()
.enumerate()
.find(|(_, l)| l.contains("->featured()->recent()"))
.map(|(i, _)| i)
.expect("should find chain line");
let recent_col = lines[chain_line]
.find("recent()")
.expect("should find recent()") as u32
+ 1;
let h_recent = backend.handle_hover(
uri,
content,
Position {
line: chain_line as u32,
character: recent_col,
},
);
assert!(
h_recent.is_some(),
"hover should work on recent() chained after featured() (line {})",
chain_line
);
let (line, col) = find("$q->featured();", "featured();");
let h_var = backend.handle_hover(
uri,
content,
Position {
line,
character: col,
},
);
assert!(
h_var.is_some(),
"hover should work on $q->featured() (Builder variable, line {})",
line
);
}
#[test]
fn hover_nested_closure_reused_variable_does_not_crash() {
let backend = create_test_backend();
let uri = "file:///nested_closure.php";
let content = r#"<?php
namespace App;
class QueryBuilder {
public function where(string $col, mixed $val = null): static { return $this; }
public function whereNull(string $col): static { return $this; }
public function orWhere(mixed ...$args): static { return $this; }
public function whereHas(string $rel, \Closure $cb): static { return $this; }
}
class Repo {
public function list(): void {
$query = new QueryBuilder();
$query->where(function ($q) {
$q->whereNull('user_id')
->orWhere('user_id', 1)
->orWhere(function ($q): void {
$q->where('is_public', 1)
->where('is_verified', 1);
});
});
}
}
"#;
backend.update_ast(uri, content);
let line_text = content.lines().nth(17).unwrap();
let col = (line_text.len() / 2) as u32;
let position = Position {
line: 17,
character: col,
};
backend.handle_hover(uri, content, position);
}
#[test]
fn hover_variable_narrowed_by_var_and_instanceof_with_use_import() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"Contracts\\": "contracts/",
"Concrete\\": "concrete/"
}
}
}"#,
&[
(
"contracts/Provider.php",
concat!(
"<?php\n",
"namespace Contracts;\n",
"interface Provider {\n",
" public function redirect(): string;\n",
"}\n",
),
),
(
"concrete/Provider.php",
concat!(
"<?php\n",
"namespace Concrete;\n",
"class Provider implements \\Contracts\\Provider {\n",
" public function redirect(): string { return ''; }\n",
" public function stateless(): static { return $this; }\n",
"}\n",
),
),
],
);
let var_uri = "file:///test_var.php";
let var_content = concat!(
"<?php\n",
"use Concrete\\Provider;\n",
"class VarTest {\n",
" public function run(): void {\n",
" /** @var Provider $provider */\n",
" $provider = $this->getProvider();\n",
" $provider;\n",
" }\n",
"}\n",
);
let contracts_uri = format!(
"file://{}",
_dir.path().join("contracts/Provider.php").display()
);
let contracts_content =
std::fs::read_to_string(_dir.path().join("contracts/Provider.php")).unwrap();
backend.update_ast(&contracts_uri, &contracts_content);
let concrete_uri = format!(
"file://{}",
_dir.path().join("concrete/Provider.php").display()
);
let concrete_content =
std::fs::read_to_string(_dir.path().join("concrete/Provider.php")).unwrap();
backend.update_ast(&concrete_uri, &concrete_content);
let hover = hover_at(&backend, var_uri, var_content, 6, 9);
assert!(
hover.is_some(),
"hover should resolve @var-narrowed variable"
);
let text = hover_text(hover.as_ref().unwrap());
assert!(
text.contains("namespace Concrete"),
"@var Provider should resolve to Concrete\\Provider via use-map, got: {}",
text
);
assert!(
!text.contains("namespace Contracts"),
"@var Provider should NOT resolve to Contracts\\Provider, got: {}",
text
);
let instanceof_uri = "file:///test_instanceof.php";
let instanceof_content = concat!(
"<?php\n",
"use Concrete\\Provider;\n",
"class InstanceofTest {\n",
" /** @return \\Contracts\\Provider */\n",
" public function getProvider() {}\n",
" public function run(): void {\n",
" $provider = $this->getProvider();\n",
" assert($provider instanceof Provider);\n",
" $provider;\n",
" }\n",
"}\n",
);
let hover2 = hover_at(&backend, instanceof_uri, instanceof_content, 8, 9);
assert!(
hover2.is_some(),
"hover should resolve instanceof-narrowed variable"
);
let text2 = hover_text(hover2.as_ref().unwrap());
assert!(
text2.contains("namespace Concrete"),
"instanceof Provider should resolve to Concrete\\Provider via use-map, got: {}",
text2
);
assert!(
!text2.contains("namespace Contracts"),
"instanceof Provider should NOT resolve to Contracts\\Provider, got: {}",
text2
);
}
#[test]
fn hover_cross_file_property_docblock_cache_invalidation_via_method_chain() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": { "App\\": "src/" }
}
}"#,
&[
(
"src/Models/ScheduledJob.php",
r#"<?php
namespace App\Models;
/** @property class-string<Job> $class_name */
class ScheduledJob {
}
"#,
),
(
"src/Service.php",
r#"<?php
namespace App;
use App\Models\ScheduledJob;
class Service {
public function getJob(): ScheduledJob {
return new ScheduledJob();
}
public function run(): void {
$this->getJob()->class_name;
}
}
"#,
),
],
);
let sj_uri = format!(
"file://{}",
_dir.path().join("src/Models/ScheduledJob.php").display()
);
let sj_content_v1 =
std::fs::read_to_string(_dir.path().join("src/Models/ScheduledJob.php")).unwrap();
backend.update_ast(&sj_uri, &sj_content_v1);
let service_uri = format!("file://{}", _dir.path().join("src/Service.php").display());
let service_content = std::fs::read_to_string(_dir.path().join("src/Service.php")).unwrap();
let hover1 = hover_at(&backend, &service_uri, &service_content, 8, 28)
.expect("expected hover on class_name via method chain (v1)");
let text1 = hover_text(&hover1);
assert!(
text1.contains("class-string"),
"v1 hover should show class-string type via method chain, got: {}",
text1
);
let sj_content_v2 = r#"<?php
namespace App\Models;
/** @property string $class_name */
class ScheduledJob {
}
"#;
backend.update_ast(&sj_uri, sj_content_v2);
let hover2 = backend
.handle_hover(
&service_uri,
&service_content,
Position {
line: 8,
character: 28,
},
)
.expect("expected hover on class_name via method chain (v2)");
let text2 = hover_text(&hover2);
assert!(
!text2.contains("class-string"),
"v2 hover should NOT show old class-string type via method chain, got: {}",
text2
);
assert!(
text2.contains("string"),
"v2 hover should show updated string type via method chain, got: {}",
text2
);
}
#[test]
fn hover_cross_file_property_docblock_cache_invalidation_via_var_annotation() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": { "App\\": "src/" }
}
}"#,
&[
(
"src/Models/ScheduledJob.php",
r#"<?php
namespace App\Models;
/** @property class-string<Job> $class_name */
class ScheduledJob {
}
"#,
),
(
"src/Service.php",
r#"<?php
namespace App;
use App\Models\ScheduledJob;
class Service {
public function run(): void {
/** @var ScheduledJob $job */
$job = $this->fetchJob();
$job->class_name;
}
}
"#,
),
],
);
let sj_uri = format!(
"file://{}",
_dir.path().join("src/Models/ScheduledJob.php").display()
);
let sj_content_v1 =
std::fs::read_to_string(_dir.path().join("src/Models/ScheduledJob.php")).unwrap();
backend.update_ast(&sj_uri, &sj_content_v1);
let service_uri = format!("file://{}", _dir.path().join("src/Service.php").display());
let service_content = std::fs::read_to_string(_dir.path().join("src/Service.php")).unwrap();
let hover1 = hover_at(&backend, &service_uri, &service_content, 7, 15)
.expect("expected hover on class_name via @var (v1)");
let text1 = hover_text(&hover1);
assert!(
text1.contains("class-string"),
"v1 hover should show class-string type via @var, got: {}",
text1
);
let sj_content_v2 = r#"<?php
namespace App\Models;
/** @property string $class_name */
class ScheduledJob {
}
"#;
backend.update_ast(&sj_uri, sj_content_v2);
let hover2 = backend
.handle_hover(
&service_uri,
&service_content,
Position {
line: 7,
character: 15,
},
)
.expect("expected hover on class_name via @var (v2)");
let text2 = hover_text(&hover2);
assert!(
!text2.contains("class-string"),
"v2 hover should NOT show old class-string type via @var, got: {}",
text2
);
assert!(
text2.contains("string"),
"v2 hover should show updated string type via @var, got: {}",
text2
);
}
#[test]
fn hover_cross_file_property_docblock_cache_warm_then_invalidate() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": { "App\\": "src/" }
}
}"#,
&[
(
"src/Models/ScheduledJob.php",
r#"<?php
namespace App\Models;
/** @property class-string<Job> $class_name */
class ScheduledJob {
}
"#,
),
(
"src/Service.php",
r#"<?php
namespace App;
use App\Models\ScheduledJob;
class Service {
public function run(ScheduledJob $job): void {
$job->class_name;
}
}
"#,
),
],
);
let sj_uri = format!(
"file://{}",
_dir.path().join("src/Models/ScheduledJob.php").display()
);
let sj_content_v1 =
std::fs::read_to_string(_dir.path().join("src/Models/ScheduledJob.php")).unwrap();
backend.update_ast(&sj_uri, &sj_content_v1);
let service_uri = format!("file://{}", _dir.path().join("src/Service.php").display());
let service_content = std::fs::read_to_string(_dir.path().join("src/Service.php")).unwrap();
let hover1 = hover_at(&backend, &service_uri, &service_content, 5, 15)
.expect("expected hover on class_name (warm-up)");
let text1 = hover_text(&hover1);
assert!(
text1.contains("class-string"),
"warm-up hover should show class-string, got: {}",
text1
);
let hover2 = backend
.handle_hover(
&service_uri,
&service_content,
Position {
line: 5,
character: 15,
},
)
.expect("expected hover on class_name (cached)");
let text2 = hover_text(&hover2);
assert!(
text2.contains("class-string"),
"cached hover should still show class-string, got: {}",
text2
);
let sj_content_v2 = r#"<?php
namespace App\Models;
/** @property string $class_name */
class ScheduledJob {
}
"#;
backend.update_ast(&sj_uri, sj_content_v2);
let hover3 = backend
.handle_hover(
&service_uri,
&service_content,
Position {
line: 5,
character: 15,
},
)
.expect("expected hover on class_name (after invalidation)");
let text3 = hover_text(&hover3);
assert!(
!text3.contains("class-string"),
"post-invalidation hover should NOT show old class-string, got: {}",
text3
);
assert!(
text3.contains("string"),
"post-invalidation hover should show updated string type, got: {}",
text3
);
}
#[test]
fn hover_cross_file_property_docblock_cache_invalidation_psr4_then_edit() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": { "App\\": "src/" }
}
}"#,
&[
(
"src/Models/ScheduledJob.php",
r#"<?php
namespace App\Models;
/** @property class-string<Job> $class_name */
class ScheduledJob {
}
"#,
),
(
"src/Service.php",
r#"<?php
namespace App;
use App\Models\ScheduledJob;
class Service {
public function run(ScheduledJob $job): void {
$job->class_name;
}
}
"#,
),
],
);
let service_uri = format!("file://{}", _dir.path().join("src/Service.php").display());
let service_content = std::fs::read_to_string(_dir.path().join("src/Service.php")).unwrap();
let hover1 = hover_at(&backend, &service_uri, &service_content, 5, 15)
.expect("expected hover on class_name (PSR-4 lazy load)");
let text1 = hover_text(&hover1);
assert!(
text1.contains("class-string"),
"PSR-4 lazy-loaded hover should show class-string, got: {}",
text1
);
let sj_uri = format!(
"file://{}",
_dir.path().join("src/Models/ScheduledJob.php").display()
);
let sj_content_v2 = r#"<?php
namespace App\Models;
/** @property string $class_name */
class ScheduledJob {
}
"#;
backend.update_ast(&sj_uri, sj_content_v2);
let hover2 = backend
.handle_hover(
&service_uri,
&service_content,
Position {
line: 5,
character: 15,
},
)
.expect("expected hover on class_name (after PSR-4 → edit)");
let text2 = hover_text(&hover2);
assert!(
!text2.contains("class-string"),
"after editing PSR-4-loaded class, hover should NOT show old class-string, got: {}",
text2
);
assert!(
text2.contains("string"),
"after editing PSR-4-loaded class, hover should show updated string, got: {}",
text2
);
}
#[test]
fn hover_cross_file_property_docblock_cache_invalidation_dependent_class() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": { "App\\": "src/" }
}
}"#,
&[
(
"src/Models/ScheduledJob.php",
r#"<?php
namespace App\Models;
/** @property class-string<Job> $class_name */
class ScheduledJob {
}
"#,
),
(
"src/Models/ChildJob.php",
r#"<?php
namespace App\Models;
class ChildJob extends ScheduledJob {
}
"#,
),
(
"src/Service.php",
r#"<?php
namespace App;
use App\Models\ChildJob;
class Service {
public function run(ChildJob $child): void {
$child->class_name;
}
}
"#,
),
],
);
let sj_uri = format!(
"file://{}",
_dir.path().join("src/Models/ScheduledJob.php").display()
);
let sj_content_v1 =
std::fs::read_to_string(_dir.path().join("src/Models/ScheduledJob.php")).unwrap();
backend.update_ast(&sj_uri, &sj_content_v1);
let child_uri = format!(
"file://{}",
_dir.path().join("src/Models/ChildJob.php").display()
);
let child_content =
std::fs::read_to_string(_dir.path().join("src/Models/ChildJob.php")).unwrap();
backend.update_ast(&child_uri, &child_content);
let service_uri = format!("file://{}", _dir.path().join("src/Service.php").display());
let service_content = std::fs::read_to_string(_dir.path().join("src/Service.php")).unwrap();
let hover1 = hover_at(&backend, &service_uri, &service_content, 5, 17)
.expect("expected hover on class_name via ChildJob (v1)");
let text1 = hover_text(&hover1);
assert!(
text1.contains("class-string"),
"v1 hover on ChildJob should show inherited class-string, got: {}",
text1
);
let sj_content_v2 = r#"<?php
namespace App\Models;
/** @property string $class_name */
class ScheduledJob {
}
"#;
backend.update_ast(&sj_uri, sj_content_v2);
let hover2 = backend
.handle_hover(
&service_uri,
&service_content,
Position {
line: 5,
character: 17,
},
)
.expect("expected hover on class_name via ChildJob (v2)");
let text2 = hover_text(&hover2);
assert!(
!text2.contains("class-string"),
"v2 hover on ChildJob should NOT show old class-string after parent docblock edit, got: {}",
text2
);
assert!(
text2.contains("string"),
"v2 hover on ChildJob should show updated string type from parent, got: {}",
text2
);
}
#[test]
fn hover_cross_file_property_docblock_cache_invalidation_dependent_class_with_model() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/",
"Illuminate\\Database\\Eloquent\\": "vendor/laravel/framework/src/Illuminate/Database/Eloquent/"
}
}
}"#,
&[
(
"vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php",
r#"<?php
namespace Illuminate\Database\Eloquent;
abstract class Model {
}
"#,
),
(
"src/Models/ScheduledJob.php",
r#"<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** @property class-string<\App\Models\Job> $class_name */
final class ScheduledJob extends Model {
}
"#,
),
(
"src/Models/ChildJob.php",
r#"<?php
namespace App\Models;
class ChildJob extends ScheduledJob {
}
"#,
),
(
"src/Service.php",
r#"<?php
namespace App;
use App\Models\ChildJob;
class Service {
public function run(ChildJob $child): void {
$child->class_name;
}
}
"#,
),
],
);
let model_uri = format!(
"file://{}",
_dir.path()
.join("vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php")
.display()
);
let model_content = std::fs::read_to_string(
_dir.path()
.join("vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php"),
)
.unwrap();
backend.update_ast(&model_uri, &model_content);
let sj_uri = format!(
"file://{}",
_dir.path().join("src/Models/ScheduledJob.php").display()
);
let sj_content_v1 =
std::fs::read_to_string(_dir.path().join("src/Models/ScheduledJob.php")).unwrap();
backend.update_ast(&sj_uri, &sj_content_v1);
let child_uri = format!(
"file://{}",
_dir.path().join("src/Models/ChildJob.php").display()
);
let child_content =
std::fs::read_to_string(_dir.path().join("src/Models/ChildJob.php")).unwrap();
backend.update_ast(&child_uri, &child_content);
let service_uri = format!("file://{}", _dir.path().join("src/Service.php").display());
let service_content = std::fs::read_to_string(_dir.path().join("src/Service.php")).unwrap();
let hover1 = hover_at(&backend, &service_uri, &service_content, 5, 17)
.expect("expected hover on class_name via ChildJob+Model (v1)");
let text1 = hover_text(&hover1);
assert!(
text1.contains("class-string"),
"v1 hover on ChildJob+Model should show inherited class-string, got: {}",
text1
);
let sj_content_v2 = r#"<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** @property string $class_name */
final class ScheduledJob extends Model {
}
"#;
backend.update_ast(&sj_uri, sj_content_v2);
let hover2 = backend
.handle_hover(
&service_uri,
&service_content,
Position {
line: 5,
character: 17,
},
)
.expect("expected hover on class_name via ChildJob+Model (v2)");
let text2 = hover_text(&hover2);
assert!(
!text2.contains("class-string"),
"v2 hover on ChildJob+Model should NOT show old class-string, got: {}",
text2
);
assert!(
text2.contains("string"),
"v2 hover on ChildJob+Model should show updated string, got: {}",
text2
);
}
#[test]
fn hover_variable_assigned_via_elvis_operator_with_static_call() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
enum Country: string {
case US = 'us';
case UK = 'uk';
/** @return array<int, self> */
public static function getActiveCountries(): array {
return [self::US, self::UK];
}
}
class Indexer {
public function index(array $markets = [], bool $shouldDelete = false): void {
$markets = $markets ?: Country::getActiveCountries();
$markets;
}
}
"#;
let hover = hover_at(&backend, uri, content, 12, 9).expect("expected hover on $markets");
let text = hover_text(&hover);
assert!(
text.contains("array<int, Country>"),
"should resolve elvis RHS static call return type, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_via_ternary_operator() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Bucket {}
class Factory {
/** @return list<Bucket> */
public static function makeBuckets(): array { return []; }
}
class Demo {
public function run(bool $flag): void {
$items = $flag ? Factory::makeBuckets() : Factory::makeBuckets();
$items;
}
}
"#;
let hover = hover_at(&backend, uri, content, 9, 9).expect("expected hover on $items");
let text = hover_text(&hover);
assert!(
text.contains("list<Bucket>"),
"should resolve ternary branches to list<Bucket>, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_via_null_coalesce_operator() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Widget {}
class Store {
/** @return list<Widget> */
public function getWidgets(): array { return []; }
public function run(): void {
$widgets = $this->getWidgets() ?? [];
$widgets;
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 9).expect("expected hover on $widgets");
let text = hover_text(&hover);
assert!(
text.contains("list<Widget>"),
"should resolve null coalesce LHS to list<Widget>, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_via_match_expression() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Alpha {}
class Beta {}
class Demo {
public function run(int $mode): void {
$result = match($mode) {
1 => new Alpha(),
2 => new Beta(),
};
$result;
}
}
"#;
let hover = hover_at(&backend, uri, content, 9, 9).expect("expected hover on $result");
let text = hover_text(&hover);
assert!(
text.contains("Alpha") || text.contains("Beta"),
"should resolve match expression arm types, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_via_elvis_with_identical_branches() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Item {}
class Repo {
/** @return list<Item> */
public function getItems(): array { return []; }
/** @return list<Item> */
public function getDefaultItems(): array { return []; }
public function run(): void {
$items = $this->getItems() ?: $this->getDefaultItems();
$items;
}
}
"#;
let hover = hover_at(&backend, uri, content, 9, 9).expect("expected hover on $items");
let text = hover_text(&hover);
assert!(
text.contains("list<Item>"),
"should resolve elvis with identical branch types to list<Item>, got: {}",
text
);
assert!(
!text.contains("list<Item>|list<Item>"),
"should not duplicate identical types in union, got: {}",
text
);
}
#[test]
fn hover_foreach_over_variable_assigned_via_elvis_with_static_call() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
enum Country: string {
case US = 'us';
case UK = 'uk';
public function label(): string { return $this->value; }
/** @return array<int, self> */
public static function getActiveCountries(): array {
return [self::US, self::UK];
}
}
class Indexer {
public function index(array $markets = []): void {
$markets = $markets ?: Country::getActiveCountries();
foreach ($markets as $market) {
$market->label();
}
}
}
"#;
let hover = hover_at(&backend, uri, content, 14, 13).expect("expected hover on $market");
let text = hover_text(&hover);
assert!(
text.contains("Country"),
"foreach value variable should resolve to Country, got: {}",
text
);
}
#[test]
fn hover_closure_param_inferred_subclass_wins_over_explicit_parent() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Model {
public function save(): bool { return true; }
}
class BrandTranslation extends Model {
public function getLangCode(): string { return ''; }
}
/**
* @template TKey
* @template TValue
*/
class Collection {
/**
* @param callable(TValue): mixed $callback
* @return static
*/
public function each(callable $callback): static {}
}
class BrandService {
/** @return Collection<int, BrandTranslation> */
public function getTranslations(): Collection {}
public function run(): void {
$translations = $this->getTranslations();
$translations->each(function (Model $brandTranslation) {
$brandTranslation->getLangCode();
});
}
}
"#;
let hover =
hover_at(&backend, uri, content, 24, 13).expect("expected hover on $brandTranslation");
let text = hover_text(&hover);
assert!(
text.contains("BrandTranslation"),
"Hover should show inferred subclass BrandTranslation, not explicit Model, got: {}",
text
);
}
#[test]
fn hover_closure_param_explicit_subclass_wins_over_inferred_parent() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Animal {
public function speak(): string { return ''; }
}
class Cat extends Animal {
public function purr(): void {}
}
/**
* @template TKey
* @template TValue
*/
class Collection {
/**
* @param callable(TValue): mixed $callback
* @return static
*/
public function each(callable $callback): static {}
}
class Shelter {
/** @return Collection<int, Animal> */
public function getAnimals(): Collection {}
public function run(): void {
$animals = $this->getAnimals();
$animals->each(function (Cat $c) {
$c->purr();
});
}
}
"#;
let hover = hover_at(&backend, uri, content, 24, 13).expect("expected hover on $c");
let text = hover_text(&hover);
assert!(
text.contains("Cat"),
"Hover should keep the explicit Cat type, got: {}",
text
);
}
#[test]
fn hover_array_shape_infers_parameter_and_property_types() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Decimal {
public function toFixed(int $decimals): string { return ''; }
}
class Tracker {
private string $websiteUuid = 'abc';
public function cartTracking(string $trackingUserId, string $url, Decimal $total, array $productIds): void {
$params = [
'websiteUuid' => $this->websiteUuid,
'trackingUserId' => $trackingUserId,
'total' => $total->toFixed(2),
'url' => $url,
];
$params;
}
}
"#;
let hover = hover_at(&backend, uri, content, 13, 9).expect("expected hover on $params");
let text = hover_text(&hover);
assert!(
text.contains("array{"),
"Hover should show array shape, got: {}",
text
);
assert!(
text.contains("websiteUuid: string"),
"websiteUuid should be string (from property), got: {}",
text
);
assert!(
text.contains("trackingUserId: string"),
"trackingUserId should be string (from parameter), got: {}",
text
);
assert!(
text.contains("total: string"),
"total should be string (from toFixed return type), got: {}",
text
);
assert!(
text.contains("url: string"),
"url should be string (from parameter), got: {}",
text
);
assert!(
!text.contains("mixed"),
"No values should be mixed, got: {}",
text
);
}
#[test]
fn hover_array_shape_infers_this_property_access() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Config {
private int $retries = 3;
private string $host = 'localhost';
public function toArray(): array {
$data = [
'retries' => $this->retries,
'host' => $this->host,
];
$data;
}
}
"#;
let hover = hover_at(&backend, uri, content, 9, 9).expect("expected hover on $data");
let text = hover_text(&hover);
assert!(
text.contains("retries: int"),
"retries should be int, got: {}",
text
);
assert!(
text.contains("host: string"),
"host should be string, got: {}",
text
);
}
#[test]
fn hover_array_shape_infers_method_call_return_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Clock {
public function now(): int { return time(); }
}
class Logger {
public function build(Clock $clock): void {
$meta = [
'timestamp' => $clock->now(),
'level' => 'info',
];
$meta;
}
}
"#;
let hover = hover_at(&backend, uri, content, 10, 9).expect("expected hover on $meta");
let text = hover_text(&hover);
assert!(
text.contains("timestamp: int"),
"timestamp should be int (from Clock::now()), got: {}",
text
);
assert!(
text.contains("level: string"),
"level should be string (from literal), got: {}",
text
);
}
#[test]
fn hover_empty_array_literal_shows_array_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
function test() {
$items = [];
$items;
}
"#;
let hover = hover_at(&backend, uri, content, 3, 5).expect("expected hover on $items");
let text = hover_text(&hover);
assert!(
text.contains("array"),
"empty array literal should resolve to array type, got: {}",
text
);
assert!(
text.contains("$items"),
"should mention the variable name, got: {}",
text
);
}
#[test]
fn hover_empty_legacy_array_literal_shows_array_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
function test() {
$items = array();
$items;
}
"#;
let hover = hover_at(&backend, uri, content, 3, 5).expect("expected hover on $items");
let text = hover_text(&hover);
assert!(
text.contains("array"),
"empty array() literal should resolve to array type, got: {}",
text
);
}
#[test]
fn hover_function_shows_extra_param_from_docblock() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @param class-string $tz
* @param string $tz2 Deprecated
*/
function formatUtfDate(string $tz): void {
$tz2 = func_get_args()[1];
}
formatUtfDate('foo');
"#;
let hover = hover_at(&backend, uri, content, 8, 2).expect("expected hover on formatUtfDate");
let text = hover_text(&hover);
assert!(
text.contains("$tz2"),
"should show extra @param $tz2 from docblock: {}",
text
);
assert!(
text.contains("Deprecated"),
"should show description for extra @param $tz2: {}",
text
);
}
#[test]
fn hover_method_shows_extra_param_from_docblock() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class DateHelper {
/**
* @param string $format
* @param string $extra Optional extra arg
*/
public function format(string $format): string {
return '';
}
}
$d = new DateHelper();
$d->format('Y-m-d');
"#;
let hover = hover_at(&backend, uri, content, 11, 6).expect("expected hover on format");
let text = hover_text(&hover);
assert!(
text.contains("$extra"),
"should show extra @param $extra from method docblock: {}",
text
);
assert!(
text.contains("Optional extra arg"),
"should show description for extra @param $extra: {}",
text
);
}
#[test]
fn hover_variable_shows_class_string_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @param class-string $tz
*/
function formatUtfDate(string $tz): void {
$tz;
}
"#;
let hover = hover_at(&backend, uri, content, 5, 5).expect("expected hover on $tz");
let text = hover_text(&hover);
assert!(
text.contains("class-string"),
"should show refined class-string type, got: {}",
text
);
}
#[test]
fn hover_function_shows_class_string_param_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @param class-string $tz
*/
function formatUtfDate(string $tz): void {}
formatUtfDate('foo');
"#;
let hover = hover_at(&backend, uri, content, 5, 2).expect("expected hover on formatUtfDate");
let text = hover_text(&hover);
assert!(
text.contains("class-string"),
"should show class-string annotation for $tz param: {}",
text
);
}
#[test]
fn hover_variable_shows_non_empty_string_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @param non-empty-string $name
*/
function greet(string $name): void {
$name;
}
"#;
let hover = hover_at(&backend, uri, content, 5, 5).expect("expected hover on $name");
let text = hover_text(&hover);
assert!(
text.contains("non-empty-string"),
"should show refined non-empty-string type, got: {}",
text
);
}
#[test]
fn hover_variable_shows_positive_int_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @param positive-int $count
*/
function repeat(int $count): void {
$count;
}
"#;
let hover = hover_at(&backend, uri, content, 5, 5).expect("expected hover on $count");
let text = hover_text(&hover);
assert!(
text.contains("positive-int"),
"should show refined positive-int type, got: {}",
text
);
}
#[test]
fn hover_extra_param_does_not_duplicate_native_params() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @param string $a First arg
* @param string $b Second arg
*/
function test(string $a, string $b): void {}
test('x', 'y');
"#;
let hover = hover_at(&backend, uri, content, 6, 2).expect("expected hover on test");
let text = hover_text(&hover);
let sig_matches = text.matches("$a").count();
assert_eq!(
sig_matches, 2,
"should have exactly two occurrences of $a (signature + description, not duplicated): {}",
text
);
}
#[test]
fn hover_variable_assigned_from_array_shape_string_key() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class User {
public function getName(): string {}
}
class Demo {
public function test(): void {
/** @var array{name: User, age: int} $data */
$data = getData();
$name = $data['name'];
$name->getName();
}
}
"#;
let hover = hover_at(&backend, uri, content, 9, 9).expect("expected hover on $name");
let text = hover_text(&hover);
assert!(
text.contains("User"),
"$name should resolve to User from array shape key access, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_from_chained_bracket_access() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Gift {
public function open(): string {}
}
class Demo {
public function test(): void {
/** @var array{items: list<Gift>} $result */
$result = getResult();
$first = $result['items'][0];
$first->open();
}
}
"#;
let hover = hover_at(&backend, uri, content, 9, 9).expect("expected hover on $first");
let text = hover_text(&hover);
assert!(
text.contains("Gift"),
"$first should resolve to Gift from chained bracket access, got: {}",
text
);
}
#[test]
fn hover_variable_type_from_shape_shows_no_namespace_corruption() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Demo {
public function test(): void {
/** @var array{data: string, items: list<int>} $result */
$result = getResult();
$result;
}
}
"#;
let hover = hover_at(&backend, uri, content, 5, 9).expect("expected hover on $result");
let text = hover_text(&hover);
assert!(
!text.contains("namespace array"),
"array shape type should not produce a 'namespace array' line, got: {}",
text
);
assert!(
text.contains("array{"),
"hover should show the array shape type, got: {}",
text
);
}
#[test]
fn hover_short_ternary_member_access() {
let backend = create_test_backend();
let uri = "file:///b6_short_ternary.php";
let content = r#"<?php
class Gadget {
public string $label = '';
}
class B6Demo {
public function run(?Gadget $a, Gadget $b): void {
($a ?: $b)->label;
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 21);
assert!(
hover.is_some(),
"hover should resolve the member through short ternary subject"
);
let text = hover_text(hover.as_ref().unwrap());
assert!(
text.contains("label"),
"hover should mention 'label', got: {}",
text
);
}
#[test]
fn hover_null_coalesce_member_access() {
let backend = create_test_backend();
let uri = "file:///b6_null_coalesce.php";
let content = r#"<?php
class Sensor {
public int $value = 0;
}
class B6Demo2 {
public function run(?Sensor $a, Sensor $b): void {
($a ?? $b)->value;
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 21);
assert!(
hover.is_some(),
"hover should resolve the member through null-coalesce subject"
);
let text = hover_text(hover.as_ref().unwrap());
assert!(
text.contains("value"),
"hover should mention 'value', got: {}",
text
);
}
#[test]
fn hover_full_ternary_member_access() {
let backend = create_test_backend();
let uri = "file:///b6_full_ternary.php";
let content = r#"<?php
class Engine {
public function start(): void {}
}
class B6Demo3 {
public function run(bool $flag, Engine $a, Engine $b): void {
($flag ? $a : $b)->start();
}
}
"#;
let hover = hover_at(&backend, uri, content, 6, 28);
assert!(
hover.is_some(),
"hover should resolve the member through full ternary subject"
);
let text = hover_text(hover.as_ref().unwrap());
assert!(
text.contains("start"),
"hover should mention 'start', got: {}",
text
);
}
#[test]
fn hover_null_coalesce_non_nullable_lhs_shows_only_lhs_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Pen {
public function write(): void {}
}
class Marker {
public function draw(): void {}
}
class Svc {
public function test(): void {
$a = new Pen() ?? new Marker();
$a->write();
}
}
"#;
let hover = hover_at(&backend, uri, content, 10, 9).expect("expected hover on $a");
let text = hover_text(&hover);
assert!(
text.contains("Pen"),
"hover should show Pen (non-nullable LHS of ??), got: {}",
text
);
assert!(
!text.contains("Marker"),
"hover should NOT show Marker (RHS is dead code), got: {}",
text
);
}
#[test]
fn hover_null_coalesce_nullable_lhs_shows_union() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Pen {
public function write(): void {}
}
class Marker {
public function draw(): void {}
}
class Svc {
/** @return ?Pen */
public function maybePen(): ?Pen { return null; }
public function test(): void {
$b = $this->maybePen() ?? new Marker();
$b->write();
}
}
"#;
let hover = hover_at(&backend, uri, content, 12, 9).expect("expected hover on $b");
let text = hover_text(&hover);
assert!(
text.contains("Pen"),
"hover should show Pen (nullable LHS stripped of null), got: {}",
text
);
assert!(
text.contains("Marker"),
"hover should show Marker (RHS of ?? when LHS is nullable), got: {}",
text
);
}
#[test]
fn hover_null_coalesce_clone_lhs_shows_only_cloned_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Pen {
public function write(): void {}
}
class Marker {
public function draw(): void {}
}
class Svc {
public function test(Pen $p): void {
$c = clone $p ?? new Marker();
$c->write();
}
}
"#;
let hover = hover_at(&backend, uri, content, 10, 9).expect("expected hover on $c");
let text = hover_text(&hover);
assert!(
text.contains("Pen"),
"hover should show Pen (clone is non-nullable), got: {}",
text
);
assert!(
!text.contains("Marker"),
"hover should NOT show Marker (RHS is dead code after clone), got: {}",
text
);
}
#[test]
fn hover_null_coalesce_method_call_lhs_not_lost() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Widget {
public function render(): void {}
}
class DefaultWidget {
public function render(): void {}
}
class Service {
public function getWidget(): Widget { return new Widget(); }
}
class App {
public function test(): void {
$svc = new Service();
$w = $svc->getWidget() ?? new DefaultWidget();
$w->render();
}
}
"#;
let hover = hover_at(&backend, uri, content, 14, 9).expect("expected hover on $w");
let text = hover_text(&hover);
assert!(
text.contains("Widget"),
"hover should show Widget, got: {}",
text
);
}
#[test]
fn hover_null_coalesce_clone_of_method_call_shows_lhs() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Config {
public function get(): string { return ''; }
}
class Fallback {
public function get(): string { return ''; }
}
class Factory {
public function makeConfig(): Config { return new Config(); }
}
class App {
public function test(): void {
$factory = new Factory();
$cfg = clone $factory->makeConfig() ?? new Fallback();
$cfg->get();
}
}
"#;
let hover = hover_at(&backend, uri, content, 14, 9).expect("expected hover on $cfg");
let text = hover_text(&hover);
assert!(
text.contains("Config"),
"hover should show Config (clone is non-nullable LHS of ??), got: {}",
text
);
assert!(
!text.contains("Fallback"),
"hover should NOT show Fallback (RHS is dead code after clone), got: {}",
text
);
}
#[test]
fn hover_null_coalesce_closure_invocation_lhs() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Alpha {
public function run(): void {}
}
class Beta {
public function run(): void {}
}
class Svc {
public function test(): void {
$x = (function(): Alpha { return new Alpha(); })() ?? new Beta();
$x->run();
}
}
"#;
let hover = hover_at(&backend, uri, content, 10, 9).expect("expected hover on $x");
let text = hover_text(&hover);
assert!(
text.contains("Alpha"),
"hover should include Alpha from the closure return type, got: {}",
text
);
}
#[test]
fn hover_clone_of_typed_variable() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Document {
public function save(): void {}
}
class Editor {
public function test(Document $doc): void {
$copy = clone $doc;
$copy->save();
}
}
"#;
let hover = hover_at(&backend, uri, content, 7, 9).expect("expected hover on $copy");
let text = hover_text(&hover);
assert!(
text.contains("Document"),
"hover on clone of typed param should show Document, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_from_global_constant() {
let backend = create_test_backend();
let uri = "file:///test.php";
let const_file = r#"<?php
define('MY_TIMEOUT', 30);
"#;
backend.update_ast("file:///constants.php", const_file);
let content = r#"<?php
function test() {
$timeout = MY_TIMEOUT;
echo $timeout;
}
"#;
let hover = hover_at(&backend, uri, content, 3, 10).expect("expected hover on $timeout");
let text = hover_text(&hover);
assert!(
text.contains("int"),
"variable assigned from integer constant should resolve to int, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_from_string_constant() {
let backend = create_test_backend();
let uri = "file:///test.php";
let const_file = r#"<?php
define('APP_NAME', 'PHPantom');
"#;
backend.update_ast("file:///constants.php", const_file);
let content = r#"<?php
function test() {
$name = APP_NAME;
echo $name;
}
"#;
let hover = hover_at(&backend, uri, content, 3, 10).expect("expected hover on $name");
let text = hover_text(&hover);
assert!(
text.contains("string"),
"variable assigned from string constant should resolve to string, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_from_top_level_const() {
let backend = create_test_backend();
let uri = "file:///test.php";
let const_file = r#"<?php
const MAX_RETRIES = 5;
"#;
backend.update_ast("file:///constants.php", const_file);
let content = r#"<?php
function test() {
$retries = MAX_RETRIES;
echo $retries;
}
"#;
let hover = hover_at(&backend, uri, content, 3, 10).expect("expected hover on $retries");
let text = hover_text(&hover);
assert!(
text.contains("int"),
"variable assigned from top-level const int should resolve to int, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_from_class_constant_without_type_hint() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Config {
const TIMEOUT = 30;
const NAME = 'app';
const RATE = 3.14;
const ENABLED = true;
}
function test() {
$a = Config::TIMEOUT;
$b = Config::NAME;
$c = Config::RATE;
$d = Config::ENABLED;
echo $a;
echo $b;
echo $c;
echo $d;
}
"#;
let hover_a = hover_at(&backend, uri, content, 12, 10).expect("expected hover on $a");
let text_a = hover_text(&hover_a);
assert!(
text_a.contains("int"),
"Config::TIMEOUT (int literal) should infer int, got: {}",
text_a
);
let hover_b = hover_at(&backend, uri, content, 13, 10).expect("expected hover on $b");
let text_b = hover_text(&hover_b);
assert!(
text_b.contains("string"),
"Config::NAME (string literal) should infer string, got: {}",
text_b
);
let hover_c = hover_at(&backend, uri, content, 14, 10).expect("expected hover on $c");
let text_c = hover_text(&hover_c);
assert!(
text_c.contains("float"),
"Config::RATE (float literal) should infer float, got: {}",
text_c
);
let hover_d = hover_at(&backend, uri, content, 15, 10).expect("expected hover on $d");
let text_d = hover_text(&hover_d);
assert!(
text_d.contains("bool"),
"Config::ENABLED (bool literal) should infer bool, got: {}",
text_d
);
}
#[test]
fn hover_variable_assigned_from_class_constant_with_type_hint_takes_precedence() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Settings {
public const string VERSION = '1.0';
}
function test() {
$v = Settings::VERSION;
echo $v;
}
"#;
let hover = hover_at(&backend, uri, content, 6, 10).expect("expected hover on $v");
let text = hover_text(&hover);
assert!(
text.contains("string"),
"typed class constant should use the type hint, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_from_bool_constant() {
let backend = create_test_backend();
let uri = "file:///test.php";
let const_file = r#"<?php
define('DEBUG_MODE', false);
"#;
backend.update_ast("file:///constants.php", const_file);
let content = r#"<?php
function test() {
$debug = DEBUG_MODE;
echo $debug;
}
"#;
let hover = hover_at(&backend, uri, content, 3, 10).expect("expected hover on $debug");
let text = hover_text(&hover);
assert!(
text.contains("bool"),
"variable assigned from bool constant should resolve to bool, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_from_array_constant() {
let backend = create_test_backend();
let uri = "file:///test.php";
let const_file = r#"<?php
define('ALLOWED_HOSTS', ['localhost', '127.0.0.1']);
"#;
backend.update_ast("file:///constants.php", const_file);
let content = r#"<?php
function test() {
$hosts = ALLOWED_HOSTS;
echo $hosts;
}
"#;
let hover = hover_at(&backend, uri, content, 3, 10).expect("expected hover on $hosts");
let text = hover_text(&hover);
assert!(
text.contains("array"),
"variable assigned from array constant should resolve to array, got: {}",
text
);
}
#[test]
fn hover_guard_clause_falsy_continue_narrows_null() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class OrderLine {
public int $actualAmount = 0;
}
class Svc {
/** @param array<int, OrderLine|null> $lines */
public function test(array $lines): void {
foreach ($lines as $line) {
if (!$line) {
continue;
}
$line->actualAmount;
}
}
}
"#;
let hover = hover_at(&backend, uri, content, 11, 13).expect("expected hover on $line");
let text = hover_text(&hover);
assert!(
text.contains("OrderLine") && !text.contains("null"),
"after `if (!$line) {{ continue; }}`, hover should show OrderLine without null, got: {}",
text
);
}
#[test]
fn hover_guard_clause_null_identity_return_narrows() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Formatter {
public function format(string $s): string { return $s; }
}
class Svc {
public function test(?Formatter $fmt): void {
if ($fmt === null) {
return;
}
$fmt->format('hello');
}
}
"#;
let hover = hover_at(&backend, uri, content, 9, 9).expect("expected hover on $fmt");
let text = hover_text(&hover);
assert!(
text.contains("Formatter") && !text.contains("null"),
"after `if ($fmt === null) {{ return; }}`, hover should show Formatter without null, got: {}",
text
);
}
#[test]
fn hover_guard_clause_null_coalesce_then_falsy_continue() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class OrderLine {
public int $actualAmount = 0;
public int $amount = 0;
}
class Svc {
/** @param array<int, OrderLine> $warehouseOrderLines */
public function test(array $warehouseOrderLines): void {
foreach ($warehouseOrderLines as $key => $val) {
$warehouseOrderline = $warehouseOrderLines[$key] ?? null;
if (!$warehouseOrderline) {
continue;
}
$warehouseOrderline->actualAmount;
}
}
}
"#;
let hover =
hover_at(&backend, uri, content, 13, 17).expect("expected hover on $warehouseOrderline");
let text = hover_text(&hover);
assert!(
text.contains("OrderLine") && !text.contains("null"),
"after null coalesce + falsy guard, hover should show OrderLine without null, got: {}",
text
);
}
#[test]
fn hover_inline_var_cast_does_not_override_rhs_without_varname() {
let backend = create_test_backend();
let uri = "file:///b15_hover_no_varname.php";
let content = r#"<?php
class Data {
public function toArray(): array { return []; }
public function count(): int { return 0; }
}
class Test {
public function run(Data $data): array {
/** @var array<string, mixed> */
$data = $data->toArray();
return $data;
}
}
"#;
let hover = hover_at(&backend, uri, content, 8, 17).expect("expected hover on RHS $data");
let text = hover_text(&hover);
assert!(
text.contains("Data"),
"RHS $data should resolve to Data (the previous type), got: {}",
text
);
assert!(
!text.contains("array<string, mixed>"),
"RHS $data should NOT show the @var cast type, got: {}",
text
);
}
#[test]
fn hover_inline_var_cast_does_not_override_rhs_with_varname() {
let backend = create_test_backend();
let uri = "file:///b15_hover_varname.php";
let content = r#"<?php
class ApiResponse {
public function getBody(): string { return ''; }
public function json(): array { return []; }
}
class Test {
public function handle(ApiResponse $response): array {
/** @var array<string, mixed> $response */
$response = $response->json();
return $response;
}
}
"#;
let hover = hover_at(&backend, uri, content, 8, 21).expect("expected hover on RHS $response");
let text = hover_text(&hover);
assert!(
text.contains("ApiResponse"),
"RHS $response should resolve to ApiResponse (the previous type), got: {}",
text
);
assert!(
!text.contains("array<string, mixed>"),
"RHS $response should NOT show the @var cast type, got: {}",
text
);
}
#[test]
fn hover_inline_var_cast_applies_after_assignment() {
let backend = create_test_backend();
let uri = "file:///b15_hover_after.php";
let content = r#"<?php
class Data {
public function toArray(): array { return []; }
}
class Wrapper {
public string $name;
}
class Test {
public function run(Data $data): void {
/** @var Wrapper */
$data = $data->toArray();
$data->name;
}
}
"#;
let hover =
hover_at(&backend, uri, content, 11, 9).expect("expected hover on $data after assignment");
let text = hover_text(&hover);
assert!(
text.contains("Wrapper"),
"@var override should apply after the assignment, got: {}",
text
);
}
#[test]
fn hover_standalone_var_annotation_still_applies() {
let backend = create_test_backend();
let uri = "file:///b15_hover_standalone.php";
let content = r#"<?php
class Formatter {
public function format(string $s): string { return $s; }
}
class Test {
public function run(): void {
$data = get_data();
/** @var Formatter $data */
$data->format('hello');
}
}
"#;
let hover = hover_at(&backend, uri, content, 8, 9)
.expect("expected hover on $data after standalone @var");
let text = hover_text(&hover);
assert!(
text.contains("Formatter"),
"standalone @var annotation should apply, got: {}",
text
);
}
#[test]
fn hover_collection_single_generic_arg_resolves_value_type() {
let backend = create_test_backend();
let uri = "file:///test_collection_single_arg.php";
let content = r#"<?php
/**
* @template TKey of array-key
* @template-covariant TValue
*/
class Collection {
/**
* @param callable|string $key
* @return static
*/
public function where($key, $operator = null, $value = null) { return $this; }
/**
* @template TFirstDefault
* @param (callable(TValue, TKey): bool)|null $callback
* @param TFirstDefault|(\Closure(): TFirstDefault) $default
* @return TValue|TFirstDefault
*/
public function first(?callable $callback = null, $default = null) { return null; }
}
class SectionTranslation {
public string $title = '';
public bool $enabled = false;
}
class Section {
/** @var Collection<SectionTranslation> */
public $translations;
}
class Test {
public function run(Section $section): void {
$result = $section->translations->where('lang_code', 'en')->first();
$result;
}
}
"#;
let hover = hover_at(&backend, uri, content, 34, 9).expect("expected hover on $result");
let text = hover_text(&hover);
assert!(
text.contains("SectionTranslation"),
"Hover should resolve TValue to SectionTranslation via right-aligned generic arg, got: {}",
text
);
assert!(
!text.contains("TValue"),
"Hover should not show raw template param TValue, got: {}",
text
);
assert!(
!text.contains("TFirstDefault"),
"TFirstDefault should resolve to null (parameter default), got: {}",
text
);
assert!(
text.contains("null"),
"Hover should include null from the resolved TFirstDefault default, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_in_if_condition() {
let backend = create_test_backend();
let uri = "file:///test.php";
let php = r#"<?php
class AdminUser {
public function assignRole(string $role): void {}
/** @return ?static */
public static function first(): ?static { return new static(); }
}
function test(string $role): void {
if ($admin = AdminUser::first()) {
$admin->assignRole($role);
}
}
"#;
let hover = hover_at(&backend, uri, php, 8, 9);
assert!(hover.is_some(), "should produce hover for $admin");
let text = hover_text(hover.as_ref().unwrap());
eprintln!("if-condition assignment hover text: {}", text);
assert!(
text.contains("AdminUser"),
"hover should resolve $admin to AdminUser inside if-body, got: {}",
text
);
assert!(
!text.contains("null"),
"hover should not include null inside truthy if-body, got: {}",
text
);
}
#[test]
fn hover_nullable_template_return_type_preserves_null() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template TValue
*/
class Builder {
/** @return TValue|null */
public function first() {}
/** @return static */
public static function query(): static { return new static(); }
}
/**
* @extends Builder<AdminUser>
*/
class AdminUser extends Builder {
public function assignRole(string $role): void {}
}
function test(): void {
$builder = AdminUser::query();
$admin = $builder->first();
$admin;
}
"#;
let hover = hover_at(&backend, uri, content, 22, 5);
assert!(hover.is_some(), "should produce hover for $admin");
let text = hover_text(hover.as_ref().unwrap());
eprintln!("nullable template hover text: {}", text);
assert!(
text.contains("AdminUser"),
"hover should resolve $admin to AdminUser, got: {}",
text
);
assert!(
text.contains("null"),
"hover should preserve |null from TValue|null after substitution, got: {}",
text
);
}
#[test]
fn hover_nullable_shorthand_template_return_type_preserves_null() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
/**
* @template TValue
*/
class Builder2 {
/** @return ?TValue */
public function first() {}
/** @return static */
public static function query(): static { return new static(); }
}
/**
* @extends Builder2<AdminUser2>
*/
class AdminUser2 extends Builder2 {}
function test2(): void {
$builder = AdminUser2::query();
$admin = $builder->first();
$admin;
}
"#;
let hover = hover_at(&backend, uri, content, 20, 5);
assert!(hover.is_some(), "should produce hover for $admin");
let text = hover_text(hover.as_ref().unwrap());
eprintln!("nullable ?TValue hover text: {}", text);
assert!(
text.contains("AdminUser2"),
"hover should resolve $admin to AdminUser2, got: {}",
text
);
assert!(
text.contains("?") || text.contains("null"),
"hover should preserve nullability from ?TValue after substitution, got: {}",
text
);
}
#[test]
fn hover_non_generic_nullable_return_type_preserves_null() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Widget {
public function name(): string { return ''; }
}
class WidgetFactory {
/** @return Widget|null */
public function find(): ?Widget { return null; }
}
function test3(): void {
$factory = new WidgetFactory();
$w = $factory->find();
$w;
}
"#;
let hover = hover_at(&backend, uri, content, 13, 5);
assert!(hover.is_some(), "should produce hover for $w");
let text = hover_text(hover.as_ref().unwrap());
eprintln!("non-generic nullable hover text: {}", text);
assert!(
text.contains("Widget"),
"hover should resolve $w to Widget, got: {}",
text
);
assert!(
text.contains("null") || text.contains("?"),
"hover should preserve nullability from Widget|null, got: {}",
text
);
}
#[test]
fn hover_closure_param_inferred_generic_args_preserved_in_type_string() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Order {
public function getTotal(): float { return 0.0; }
}
/**
* @template T
*/
class Builder {
/** @return static */
public function where(string $col, mixed $val = null): static { return $this; }
}
class Processor {
/**
* @param callable(Builder<Order>): mixed $callback
* @return void
*/
public function apply(callable $callback): void {}
public function run(): void {
$this->apply(function (Builder $q) {
$q;
});
}
}
"#;
let hover = hover_at(&backend, uri, content, 22, 13).expect("expected hover on $q");
let text = hover_text(&hover);
assert!(
text.contains("Builder<"),
"Hover should show Builder<…> with generic param, not bare Builder, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_from_chained_method_preserves_generic_params() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Order {
public function getTotal(): float { return 0.0; }
}
/**
* @template T
*/
class Builder {
/** @return static */
public function where(string $col, mixed $val = null): static { return $this; }
}
class Processor {
/**
* @param callable(Builder<Order>): mixed $callback
* @return void
*/
public function apply(callable $callback): void {}
public function run(): void {
$this->apply(function (Builder $q) {
$a = $q->where('published', 1);
$a;
});
}
}
"#;
let hover = hover_at(&backend, uri, content, 23, 13).expect("expected hover on $a");
let text = hover_text(&hover);
assert!(
text.contains("Builder<"),
"Hover on $a should show Builder<…> with generic param (not bare Builder), got: {}",
text
);
}
#[test]
fn hover_variable_assigned_from_multi_step_chain_preserves_generic_params() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Article {
public function getTitle(): string { return ''; }
}
/**
* @template T
*/
class Builder {
/** @return static */
public function where(string $col, mixed $val = null): static { return $this; }
/** @return static */
public function orderBy(string $col): static { return $this; }
}
class Service {
/**
* @param callable(Builder<Article>): mixed $cb
*/
public function query(callable $cb): void {}
public function run(): void {
$this->query(function (Builder $q) {
$b = $q->where('published', 1)->orderBy('title');
$b;
});
}
}
"#;
let hover = hover_at(&backend, uri, content, 23, 13).expect("expected hover on $b");
let text = hover_text(&hover);
assert!(
text.contains("Builder<"),
"Hover on $b (multi-step chain) should show Builder<…>, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_from_method_on_generic_variable_preserves_params() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class User {
public int $id;
}
/**
* @template TModel
*/
class Collection {
/** @return static */
public function filter(callable $cb): static { return $this; }
/** @return static */
public function values(): static { return $this; }
}
class Handler {
/**
* @param Collection<User> $users
*/
public function handle(Collection $users): void {
$filtered = $users->filter(fn($u) => $u->id > 0);
$filtered;
}
}
"#;
let hover = hover_at(&backend, uri, content, 21, 9).expect("expected hover on $filtered");
let text = hover_text(&hover);
assert!(
text.contains("Collection<"),
"Hover on $filtered should show Collection<…> with generic param, got: {}",
text
);
}
#[test]
fn hover_variable_generic_preserved_after_prior_member_hover() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Article {
public function getTitle(): string { return ''; }
}
/**
* @template T
*/
class Builder {
/** @return static */
public function where(string $col, mixed $val = null): static { return $this; }
/** @return static */
public function whereLanguage(string $lang): static { return $this; }
}
class Repo {
/**
* @param callable(Builder<Article>): mixed $cb
*/
public function query(callable $cb): void {}
public function run(): void {
$this->query(function (Builder $q) {
$a = $q->where('published', 1);
$a->whereLanguage('en');
});
}
}
"#;
let hover_q = hover_at(&backend, uri, content, 23, 18);
let q_text = hover_q
.as_ref()
.map(|h| hover_text(h).to_string())
.unwrap_or_else(|| "(none)".to_string());
assert!(hover_q.is_some(), "hover on $q should resolve");
let hover_var = hover_at(&backend, uri, content, 23, 13);
let a_text = hover_var
.as_ref()
.map(|h| hover_text(h).to_string())
.unwrap_or_else(|| "(none)".to_string());
assert!(
a_text.contains("Builder<"),
"Hover on $a (after prior $q hover) should show Builder<…> with generic param, not bare Builder.\n\
$q hover returned: {}\n\
$a hover returned: {}",
q_text,
a_text
);
}
#[test]
fn hover_variable_at_dollar_sign_resolves_assignment_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Order { public string $id; }
class Service {
public function run(): void {
$order = new Order();
$order->id;
}
}
"#;
let hover_letter =
hover_at(&backend, uri, content, 4, 9).expect("hover on variable letter should resolve");
let text_letter = hover_text(&hover_letter);
assert!(
text_letter.contains("Order"),
"Hover on `o` of `$order` should show Order, got: {}",
text_letter
);
let hover_dollar = hover_at(&backend, uri, content, 4, 8);
let text_dollar = hover_dollar
.as_ref()
.map(|h| hover_text(h).to_string())
.unwrap_or_else(|| "(none)".to_string());
assert!(
text_dollar.contains("Order"),
"Hover on `$` of `$order` should show Order, got: {}",
text_dollar
);
}
#[test]
fn hover_variable_key_string_produces_array_string_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Pen {
public function write(): string { return ''; }
public function color(): string { return ''; }
}
class Svc {
/** @param list<Pen> $pens */
public function run(array $pens): void {
$indexed = [];
foreach ($pens as $pen) {
$key = $pen->color();
$indexed[$key] = $pen;
}
$indexed;
}
}
"#;
let hover = hover_at(&backend, uri, content, 13, 9).expect("expected hover on $indexed");
let text = hover_text(&hover);
assert!(
text.contains("array<string, Pen>"),
"Variable-key assignment with string key should produce array<string, Pen>, got: {}",
text
);
}
#[test]
fn hover_variable_key_int_produces_array_int_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Pen {
public function write(): string { return ''; }
}
function run(int $id): void {
$indexed = [];
$indexed[$id] = new Pen();
$indexed;
}
"#;
let hover = hover_at(&backend, uri, content, 7, 5).expect("expected hover on $indexed");
let text = hover_text(&hover);
assert!(
text.contains("array<int, Pen>"),
"Variable-key assignment with int key should produce array<int, Pen>, got: {}",
text
);
}
#[test]
fn hover_variable_key_unknown_produces_array_intstring_value() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Pen {
public function write(): string { return ''; }
}
function run(mixed $key): void {
$indexed = [];
$indexed[$key] = new Pen();
$indexed;
}
"#;
let hover = hover_at(&backend, uri, content, 7, 5).expect("expected hover on $indexed");
let text = hover_text(&hover);
assert!(
text.contains("array<int|string, Pen>"),
"Variable-key assignment with mixed key should produce array<int|string, Pen>, got: {}",
text
);
}
#[test]
fn hover_push_style_produces_list() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class Pen {
public function write(): string { return ''; }
}
function run(): void {
$items = [];
$items[] = new Pen();
$items;
}
"#;
let hover = hover_at(&backend, uri, content, 7, 5).expect("expected hover on $items");
let text = hover_text(&hover);
assert!(
text.contains("list<Pen>"),
"Push-style assignment should produce list<Pen>, got: {}",
text
);
}
#[test]
fn hover_variable_assigned_from_conditional_return_shows_resolved_type() {
let backend = create_test_backend();
let uri = "file:///test.php";
let content = r#"<?php
class SubmissionProducerMessage {
public function getId(): int {}
}
class Serializer {
/**
* @template TObject of object
* @template TType of string|class-string<TObject>
*
* @param TType $type
* @param array<string, mixed> $context
*
* @phpstan-return ($type is class-string<TObject> ? TObject : mixed)
* @psalm-return (TType is class-string<TObject> ? TObject : mixed)
*/
public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed {}
}
class Consumer {
private Serializer $serializer;
public function handle(string $buffer): void {
$message = $this->serializer->deserialize($buffer, SubmissionProducerMessage::class, 'json');
$message->getId();
}
}
"#;
let hover = hover_at(&backend, uri, content, 23, 9).expect("expected hover on $message");
let text = hover_text(&hover);
assert!(
text.contains("SubmissionProducerMessage"),
"Should show SubmissionProducerMessage, got: {}",
text
);
assert!(
!text.contains("mixed"),
"Should NOT show mixed when conditional resolves to a concrete class, got: {}",
text
);
}