use crate::common::create_test_backend;
use phpantom_lsp::Visibility;
#[tokio::test]
async fn test_parse_php_extracts_class_and_methods() {
let backend = create_test_backend();
let php = "<?php\nclass User {\n function login() {}\n function logout() {}\n}\n";
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "User");
assert_eq!(classes[0].methods.len(), 2);
assert_eq!(classes[0].methods[0].name, "login");
assert_eq!(classes[0].methods[1].name, "logout");
}
#[tokio::test]
async fn test_parse_php_ignores_standalone_functions() {
let backend = create_test_backend();
let php = "<?php\nfunction standalone() {}\nclass Service {\n function handle() {}\n}\n";
let classes = backend.parse_php(php);
assert_eq!(
classes.len(),
1,
"Only class declarations should be extracted"
);
assert_eq!(classes[0].name, "Service");
assert_eq!(classes[0].methods.len(), 1);
assert_eq!(classes[0].methods[0].name, "handle");
}
#[tokio::test]
async fn test_parse_php_no_classes_returns_empty() {
let backend = create_test_backend();
let php = "<?php\nfunction foo() {}\n$x = 1;\n";
let classes = backend.parse_php(php);
assert!(classes.is_empty(), "No classes should be found");
}
#[tokio::test]
async fn test_parse_php_extracts_properties() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class User {\n",
" public string $name;\n",
" public int $age;\n",
" private $secret;\n",
" function login() {}\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(
classes[0].properties.len(),
3,
"Should extract 3 properties"
);
let prop_names: Vec<&str> = classes[0]
.properties
.iter()
.map(|p| p.name.as_str())
.collect();
assert!(prop_names.contains(&"name"), "Should contain 'name'");
assert!(prop_names.contains(&"age"), "Should contain 'age'");
assert!(prop_names.contains(&"secret"), "Should contain 'secret'");
let name_prop = classes[0]
.properties
.iter()
.find(|p| p.name == "name")
.unwrap();
assert_eq!(
name_prop.type_hint_str().as_deref(),
Some("string"),
"name property should have string type hint"
);
let age_prop = classes[0]
.properties
.iter()
.find(|p| p.name == "age")
.unwrap();
assert_eq!(
age_prop.type_hint_str().as_deref(),
Some("int"),
"age property should have int type hint"
);
let secret_prop = classes[0]
.properties
.iter()
.find(|p| p.name == "secret")
.unwrap();
assert_eq!(
secret_prop.type_hint, None,
"secret property should have no type hint"
);
}
#[tokio::test]
async fn test_parse_php_extracts_static_properties() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Counter {\n",
" public static int $count = 0;\n",
" public string $label;\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].properties.len(), 2);
let count_prop = classes[0]
.properties
.iter()
.find(|p| p.name == "count")
.expect("Should have count property");
assert!(count_prop.is_static, "count should be static");
let label_prop = classes[0]
.properties
.iter()
.find(|p| p.name == "label")
.expect("Should have label property");
assert!(!label_prop.is_static, "label should not be static");
}
#[tokio::test]
async fn test_parse_php_extracts_method_return_type() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Greeter {\n",
" function greet(string $name): string {}\n",
" function doStuff() {}\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].methods.len(), 2);
let greet = &classes[0].methods[0];
assert_eq!(greet.name, "greet");
assert_eq!(
greet.return_type_str().as_deref(),
Some("string"),
"greet should have return type 'string'"
);
assert_eq!(greet.parameters.len(), 1);
assert_eq!(greet.parameters[0].name, "$name");
assert!(greet.parameters[0].is_required);
assert_eq!(
greet.parameters[0].type_hint_str().as_deref(),
Some("string")
);
let do_stuff = &classes[0].methods[1];
assert_eq!(do_stuff.name, "doStuff");
assert_eq!(
do_stuff.return_type, None,
"doStuff should have no return type"
);
}
#[tokio::test]
async fn test_parse_php_method_parameter_info() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Service {\n",
" function process(string $input, int $count, ?string $label = null, ...$extras): bool {}\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let method = &classes[0].methods[0];
assert_eq!(method.name, "process");
assert_eq!(method.parameters.len(), 4);
let input = &method.parameters[0];
assert_eq!(input.name, "$input");
assert!(input.is_required);
assert_eq!(input.type_hint_str().as_deref(), Some("string"));
assert!(!input.is_variadic);
let count = &method.parameters[1];
assert_eq!(count.name, "$count");
assert!(count.is_required);
assert_eq!(count.type_hint_str().as_deref(), Some("int"));
let label = &method.parameters[2];
assert_eq!(label.name, "$label");
assert!(
!label.is_required,
"$label has a default value, should not be required"
);
assert_eq!(label.type_hint_str().as_deref(), Some("?string"));
let extras = &method.parameters[3];
assert_eq!(extras.name, "$extras");
assert!(
!extras.is_required,
"variadic params should not be required"
);
assert!(extras.is_variadic);
}
#[tokio::test]
async fn test_parse_php_property_with_default_value() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Settings {\n",
" public bool $debug = false;\n",
" public string $title = 'default';\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].properties.len(), 2);
let prop_names: Vec<&str> = classes[0]
.properties
.iter()
.map(|p| p.name.as_str())
.collect();
assert!(prop_names.contains(&"debug"));
assert!(prop_names.contains(&"title"));
}
#[tokio::test]
async fn test_parse_php_class_inside_implicit_namespace() {
let backend = create_test_backend();
let php = "<?php\nnamespace Demo;\n\nclass User {\n function login() {}\n function logout() {}\n}\n";
let classes = backend.parse_php(php);
assert_eq!(
classes.len(),
1,
"Should find class inside implicit namespace"
);
assert_eq!(classes[0].name, "User");
assert_eq!(classes[0].methods.len(), 2);
assert_eq!(classes[0].methods[0].name, "login");
assert_eq!(classes[0].methods[1].name, "logout");
}
#[tokio::test]
async fn test_parse_php_class_inside_brace_delimited_namespace() {
let backend = create_test_backend();
let php =
"<?php\nnamespace Demo {\n class Service {\n function handle() {}\n }\n}\n";
let classes = backend.parse_php(php);
assert_eq!(
classes.len(),
1,
"Should find class inside brace-delimited namespace"
);
assert_eq!(classes[0].name, "Service");
assert_eq!(classes[0].methods.len(), 1);
assert_eq!(classes[0].methods[0].name, "handle");
}
#[tokio::test]
async fn test_parse_php_multiple_classes_in_brace_delimited_namespaces() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"namespace Foo {\n",
" class A {\n",
" function doA() {}\n",
" }\n",
"}\n",
"namespace Bar {\n",
" class B {\n",
" function doB() {}\n",
" }\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 2, "Should find classes in both namespaces");
assert_eq!(classes[0].name, "A");
assert_eq!(classes[0].methods.len(), 1);
assert_eq!(classes[0].methods[0].name, "doA");
assert_eq!(classes[1].name, "B");
assert_eq!(classes[1].methods.len(), 1);
assert_eq!(classes[1].methods[0].name, "doB");
}
#[tokio::test]
async fn test_parse_php_static_method() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Factory {\n",
" public static function create(string $type): self {}\n",
" public function build(): void {}\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].methods.len(), 2);
let create = &classes[0].methods[0];
assert_eq!(create.name, "create");
assert!(create.is_static, "create should be static");
assert_eq!(create.parameters.len(), 1);
assert_eq!(create.parameters[0].name, "$type");
let build = &classes[0].methods[1];
assert_eq!(build.name, "build");
assert!(!build.is_static, "build should not be static");
}
#[tokio::test]
async fn test_parse_php_extracts_constants() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Config {\n",
" const VERSION = '1.0';\n",
" const int MAX_RETRIES = 3;\n",
" public string $name;\n",
" public function getName(): string {}\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].constants.len(), 2);
let version = &classes[0].constants[0];
assert_eq!(version.name, "VERSION");
assert!(version.type_hint.is_none(), "VERSION has no type hint");
let max_retries = &classes[0].constants[1];
assert_eq!(max_retries.name, "MAX_RETRIES");
assert_eq!(
max_retries.type_hint_str().as_deref(),
Some("int"),
"MAX_RETRIES should have int type hint"
);
}
#[tokio::test]
async fn test_parse_php_extracts_multiple_constants_in_one_declaration() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Status {\n",
" const ACTIVE = 1, INACTIVE = 0;\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].constants.len(), 2);
assert_eq!(classes[0].constants[0].name, "ACTIVE");
assert_eq!(classes[0].constants[1].name, "INACTIVE");
}
#[tokio::test]
async fn test_parse_php_extracts_parent_class() {
let backend = create_test_backend();
let classes = backend.parse_php(concat!(
"<?php\n",
"class Animal {\n",
" public function breathe(): void {}\n",
"}\n",
"class Dog extends Animal {\n",
" public function bark(): void {}\n",
"}\n",
));
assert_eq!(classes.len(), 2);
assert_eq!(classes[0].name, "Animal");
assert!(classes[0].parent_class.is_none());
assert_eq!(classes[1].name, "Dog");
assert_eq!(classes[1].parent_class.as_deref(), Some("Animal"));
}
#[tokio::test]
async fn test_parse_php_extracts_visibility() {
let backend = create_test_backend();
let classes = backend.parse_php(concat!(
"<?php\n",
"class Foo {\n",
" public function pubMethod(): void {}\n",
" protected function protMethod(): void {}\n",
" private function privMethod(): void {}\n",
" function defaultMethod(): void {}\n",
" public string $pubProp;\n",
" protected string $protProp;\n",
" private string $privProp;\n",
" public const PUB_CONST = 1;\n",
" protected const PROT_CONST = 2;\n",
" private const PRIV_CONST = 3;\n",
" const DEFAULT_CONST = 4;\n",
"}\n",
));
assert_eq!(classes.len(), 1);
let cls = &classes[0];
let pub_m = cls.methods.iter().find(|m| m.name == "pubMethod").unwrap();
assert_eq!(pub_m.visibility, Visibility::Public);
let prot_m = cls.methods.iter().find(|m| m.name == "protMethod").unwrap();
assert_eq!(prot_m.visibility, Visibility::Protected);
let priv_m = cls.methods.iter().find(|m| m.name == "privMethod").unwrap();
assert_eq!(priv_m.visibility, Visibility::Private);
let def_m = cls
.methods
.iter()
.find(|m| m.name == "defaultMethod")
.unwrap();
assert_eq!(
def_m.visibility,
Visibility::Public,
"No modifier defaults to public"
);
let pub_p = cls.properties.iter().find(|p| p.name == "pubProp").unwrap();
assert_eq!(pub_p.visibility, Visibility::Public);
let prot_p = cls
.properties
.iter()
.find(|p| p.name == "protProp")
.unwrap();
assert_eq!(prot_p.visibility, Visibility::Protected);
let priv_p = cls
.properties
.iter()
.find(|p| p.name == "privProp")
.unwrap();
assert_eq!(priv_p.visibility, Visibility::Private);
let pub_c = cls
.constants
.iter()
.find(|c| c.name == "PUB_CONST")
.unwrap();
assert_eq!(pub_c.visibility, Visibility::Public);
let prot_c = cls
.constants
.iter()
.find(|c| c.name == "PROT_CONST")
.unwrap();
assert_eq!(prot_c.visibility, Visibility::Protected);
let priv_c = cls
.constants
.iter()
.find(|c| c.name == "PRIV_CONST")
.unwrap();
assert_eq!(priv_c.visibility, Visibility::Private);
let def_c = cls
.constants
.iter()
.find(|c| c.name == "DEFAULT_CONST")
.unwrap();
assert_eq!(
def_c.visibility,
Visibility::Public,
"No modifier defaults to public"
);
}
#[tokio::test]
async fn test_parse_php_extracts_interface_methods() {
let backend = create_test_backend();
let php = r#"<?php
interface Loggable {
public function log(string $message): void;
public function getLogLevel(): int;
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "Loggable");
assert_eq!(classes[0].methods.len(), 2);
assert_eq!(classes[0].methods[0].name, "log");
assert_eq!(
classes[0].methods[0].return_type_str().as_deref(),
Some("void")
);
assert_eq!(classes[0].methods[1].name, "getLogLevel");
assert_eq!(
classes[0].methods[1].return_type_str().as_deref(),
Some("int")
);
}
#[tokio::test]
async fn test_parse_php_extracts_interface_constants() {
let backend = create_test_backend();
let php = r#"<?php
interface HasStatus {
const STATUS_ACTIVE = 1;
const STATUS_INACTIVE = 0;
public function getStatus(): int;
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "HasStatus");
assert_eq!(classes[0].constants.len(), 2);
assert_eq!(classes[0].constants[0].name, "STATUS_ACTIVE");
assert_eq!(classes[0].constants[1].name, "STATUS_INACTIVE");
assert_eq!(classes[0].methods.len(), 1);
assert_eq!(classes[0].methods[0].name, "getStatus");
}
#[tokio::test]
async fn test_parse_php_interface_extends() {
let backend = create_test_backend();
let php = r#"<?php
interface Readable {
public function read(): string;
}
interface Writable extends Readable {
public function write(string $data): void;
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 2);
let readable = classes.iter().find(|c| c.name == "Readable").unwrap();
assert!(readable.parent_class.is_none());
assert_eq!(readable.methods.len(), 1);
let writable = classes.iter().find(|c| c.name == "Writable").unwrap();
assert_eq!(writable.parent_class.as_deref(), Some("Readable"));
assert_eq!(writable.methods.len(), 1);
assert_eq!(writable.methods[0].name, "write");
}
#[tokio::test]
async fn test_parse_php_interface_inside_namespace() {
let backend = create_test_backend();
let php = r#"<?php
namespace App\Contracts;
interface Repository {
public function find(int $id): mixed;
public function save(object $entity): void;
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "Repository");
assert_eq!(classes[0].methods.len(), 2);
assert_eq!(classes[0].methods[0].name, "find");
assert_eq!(classes[0].methods[1].name, "save");
}
#[tokio::test]
async fn test_parse_php_class_and_interface_together() {
let backend = create_test_backend();
let php = r#"<?php
interface Cacheable {
public function getCacheKey(): string;
const TTL = 3600;
}
class UserRepository implements Cacheable {
public function getCacheKey(): string { return 'users'; }
public function findAll(): array { return []; }
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 2);
let iface = classes.iter().find(|c| c.name == "Cacheable").unwrap();
assert_eq!(iface.methods.len(), 1);
assert_eq!(iface.constants.len(), 1);
assert_eq!(iface.constants[0].name, "TTL");
let class = classes.iter().find(|c| c.name == "UserRepository").unwrap();
assert_eq!(class.methods.len(), 2);
}
#[tokio::test]
async fn test_parse_php_interface_static_method() {
let backend = create_test_backend();
let php = r#"<?php
interface Factory {
public static function create(): static;
public function build(): object;
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "Factory");
assert_eq!(classes[0].methods.len(), 2);
let create = classes[0]
.methods
.iter()
.find(|m| m.name == "create")
.unwrap();
assert!(create.is_static);
assert_eq!(create.return_type_str().as_deref(), Some("static"));
let build = classes[0]
.methods
.iter()
.find(|m| m.name == "build")
.unwrap();
assert!(!build.is_static);
}
#[tokio::test]
async fn test_parse_php_promoted_properties_basic() {
let backend = create_test_backend();
let php = r#"<?php
class Service {
public function __construct(
private IShoppingCart $cart,
protected Logger $logger,
) {}
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let cls = &classes[0];
assert_eq!(
cls.properties.len(),
2,
"Should extract 2 promoted properties"
);
let cart = cls.properties.iter().find(|p| p.name == "cart").unwrap();
assert_eq!(cart.type_hint_str().as_deref(), Some("IShoppingCart"));
assert_eq!(cart.visibility, Visibility::Private);
assert!(!cart.is_static);
let logger = cls.properties.iter().find(|p| p.name == "logger").unwrap();
assert_eq!(logger.type_hint_str().as_deref(), Some("Logger"));
assert_eq!(logger.visibility, Visibility::Protected);
assert!(!logger.is_static);
}
#[tokio::test]
async fn test_parse_php_promoted_properties_mixed_with_regular() {
let backend = create_test_backend();
let php = r#"<?php
class ShoppingCartService {
private IShoppingCart $regular;
public function __construct(
private IShoppingCart $promoted,
) {}
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let cls = &classes[0];
assert_eq!(
cls.properties.len(),
2,
"Should have regular + promoted property"
);
let regular = cls.properties.iter().find(|p| p.name == "regular").unwrap();
assert_eq!(regular.type_hint_str().as_deref(), Some("IShoppingCart"));
assert_eq!(regular.visibility, Visibility::Private);
let promoted = cls
.properties
.iter()
.find(|p| p.name == "promoted")
.unwrap();
assert_eq!(promoted.type_hint_str().as_deref(), Some("IShoppingCart"));
assert_eq!(promoted.visibility, Visibility::Private);
}
#[tokio::test]
async fn test_parse_php_promoted_property_public_visibility() {
let backend = create_test_backend();
let php = r#"<?php
class Config {
public function __construct(
public string $name,
public int $value,
) {}
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let cls = &classes[0];
assert_eq!(cls.properties.len(), 2);
for prop in &cls.properties {
assert_eq!(prop.visibility, Visibility::Public);
}
let name = cls.properties.iter().find(|p| p.name == "name").unwrap();
assert_eq!(name.type_hint_str().as_deref(), Some("string"));
let value = cls.properties.iter().find(|p| p.name == "value").unwrap();
assert_eq!(value.type_hint_str().as_deref(), Some("int"));
}
#[tokio::test]
async fn test_parse_php_non_promoted_constructor_params_ignored() {
let backend = create_test_backend();
let php = r#"<?php
class Service {
public function __construct(
private string $promoted,
string $regularParam,
) {}
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let cls = &classes[0];
assert_eq!(
cls.properties.len(),
1,
"Only promoted params (with visibility) should become properties"
);
assert_eq!(cls.properties[0].name, "promoted");
}
#[tokio::test]
async fn test_parse_php_promoted_property_readonly() {
let backend = create_test_backend();
let php = r#"<?php
class User {
public function __construct(
public readonly string $name,
private readonly int $id,
) {}
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let cls = &classes[0];
assert_eq!(
cls.properties.len(),
2,
"readonly promoted params are still promoted"
);
let name = cls.properties.iter().find(|p| p.name == "name").unwrap();
assert_eq!(name.visibility, Visibility::Public);
assert_eq!(name.type_hint_str().as_deref(), Some("string"));
let id = cls.properties.iter().find(|p| p.name == "id").unwrap();
assert_eq!(id.visibility, Visibility::Private);
assert_eq!(id.type_hint_str().as_deref(), Some("int"));
}
#[tokio::test]
async fn test_parse_promoted_property_param_docblock_override() {
let backend = create_test_backend();
let php = r#"<?php
class UserService {
/**
* @param list<User> $users
* @param string $name
*/
public function __construct(
public array $users,
public string $name,
) {}
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let cls = &classes[0];
let users = cls.properties.iter().find(|p| p.name == "users").unwrap();
assert_eq!(
users.type_hint_str().as_deref(),
Some("list<User>"),
"@param list<User> should override native `array` for promoted property"
);
let name = cls.properties.iter().find(|p| p.name == "name").unwrap();
assert_eq!(
name.type_hint_str().as_deref(),
Some("string"),
"Scalar @param string should keep native `string`"
);
}
#[tokio::test]
async fn test_parse_promoted_property_param_class_override() {
let backend = create_test_backend();
let php = r#"<?php
class Repository {
/**
* @param UserCollection $items
*/
public function __construct(
public object $items,
) {}
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let cls = &classes[0];
let items = cls.properties.iter().find(|p| p.name == "items").unwrap();
assert_eq!(
items.type_hint_str().as_deref(),
Some("UserCollection"),
"@param UserCollection should override native `object` for promoted property"
);
}
#[tokio::test]
async fn test_parse_promoted_property_no_docblock_unchanged() {
let backend = create_test_backend();
let php = r#"<?php
class Service {
public function __construct(
public array $items,
private string $name,
) {}
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let cls = &classes[0];
let items = cls.properties.iter().find(|p| p.name == "items").unwrap();
assert_eq!(items.type_hint_str().as_deref(), Some("array"));
let name = cls.properties.iter().find(|p| p.name == "name").unwrap();
assert_eq!(name.type_hint_str().as_deref(), Some("string"));
}
#[tokio::test]
async fn test_parse_promoted_property_param_only_matching() {
let backend = create_test_backend();
let php = r#"<?php
class Service {
/**
* @param LoggerInterface $logger
*/
public function __construct(
public LoggerInterface $logger,
public array $data,
) {}
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let cls = &classes[0];
let logger = cls.properties.iter().find(|p| p.name == "logger").unwrap();
assert_eq!(logger.type_hint_str().as_deref(), Some("LoggerInterface"));
let data = cls.properties.iter().find(|p| p.name == "data").unwrap();
assert_eq!(data.type_hint_str().as_deref(), Some("array"));
}
#[tokio::test]
async fn test_parse_promoted_property_param_scalar_not_overridden_by_class() {
let backend = create_test_backend();
let php = r#"<?php
class Service {
/**
* @param UserId $id
*/
public function __construct(
public int $id,
) {}
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let cls = &classes[0];
let id = cls.properties.iter().find(|p| p.name == "id").unwrap();
assert_eq!(
id.type_hint_str().as_deref(),
Some("int"),
"Native scalar `int` should not be overridden by docblock class `UserId`"
);
}
#[tokio::test]
async fn test_parse_promoted_property_param_generic_override() {
let backend = create_test_backend();
let php = r#"<?php
class OrderService {
/**
* @param Collection<int, Order> $orders
* @param array<string, mixed> $config
*/
public function __construct(
public object $orders,
public array $config,
) {}
}
"#;
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let cls = &classes[0];
let orders = cls.properties.iter().find(|p| p.name == "orders").unwrap();
assert_eq!(
orders.type_hint_str().as_deref(),
Some("Collection<int, Order>"),
"@param Collection<int, Order> should override native `object`"
);
let config = cls.properties.iter().find(|p| p.name == "config").unwrap();
assert_eq!(
config.type_hint_str().as_deref(),
Some("array<string, mixed>"),
"Docblock `array<string, mixed>` should override native `array` (generic params preserved)"
);
}
#[tokio::test]
async fn test_parse_functions_standalone() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"function hello(): void {}\n",
"function add(int $a, int $b): int { return $a + $b; }\n",
);
let functions = backend.parse_functions(php);
assert_eq!(functions.len(), 2, "Should extract 2 standalone functions");
let hello = functions.iter().find(|f| f.name == "hello").unwrap();
assert!(hello.parameters.is_empty());
assert_eq!(hello.return_type_str().as_deref(), Some("void"));
assert!(hello.namespace.is_none());
let add = functions.iter().find(|f| f.name == "add").unwrap();
assert_eq!(add.parameters.len(), 2);
assert_eq!(add.parameters[0].name, "$a");
assert_eq!(add.parameters[0].type_hint_str().as_deref(), Some("int"));
assert_eq!(add.parameters[1].name, "$b");
assert_eq!(add.return_type_str().as_deref(), Some("int"));
assert!(add.namespace.is_none());
}
#[tokio::test]
async fn test_parse_functions_inside_namespace() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"namespace Amp;\n",
"\n",
"function delay(float $seconds): void {}\n",
"function async(callable $callback): void {}\n",
);
let functions = backend.parse_functions(php);
assert_eq!(functions.len(), 2, "Should extract 2 namespaced functions");
let delay = functions.iter().find(|f| f.name == "delay").unwrap();
assert_eq!(delay.namespace.as_deref(), Some("Amp"));
assert_eq!(delay.parameters.len(), 1);
assert_eq!(delay.parameters[0].name, "$seconds");
assert_eq!(
delay.parameters[0].type_hint_str().as_deref(),
Some("float")
);
assert_eq!(delay.return_type_str().as_deref(), Some("void"));
let async_fn = functions.iter().find(|f| f.name == "async").unwrap();
assert_eq!(async_fn.namespace.as_deref(), Some("Amp"));
}
#[tokio::test]
async fn test_parse_functions_ignores_class_methods() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"function standalone(): void {}\n",
"class Service {\n",
" public function handle(): void {}\n",
"}\n",
"function another(): string { return ''; }\n",
);
let functions = backend.parse_functions(php);
assert_eq!(
functions.len(),
2,
"Should only extract standalone functions, not class methods"
);
assert!(functions.iter().any(|f| f.name == "standalone"));
assert!(functions.iter().any(|f| f.name == "another"));
assert!(
!functions.iter().any(|f| f.name == "handle"),
"Class methods should not appear"
);
}
#[tokio::test]
async fn test_parse_functions_no_return_type() {
let backend = create_test_backend();
let php = "<?php\nfunction legacy($x, $y) { return $x + $y; }\n";
let functions = backend.parse_functions(php);
assert_eq!(functions.len(), 1);
let f = &functions[0];
assert_eq!(f.name, "legacy");
assert!(f.return_type.is_none(), "No return type hint");
assert_eq!(f.parameters.len(), 2);
assert!(f.parameters[0].type_hint.is_none());
}
#[tokio::test]
async fn test_parse_functions_nullable_and_union_types() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"function maybe(?string $val): ?int { return null; }\n",
"function either(string|int $val): string|false { return ''; }\n",
);
let functions = backend.parse_functions(php);
assert_eq!(functions.len(), 2);
let maybe = functions.iter().find(|f| f.name == "maybe").unwrap();
assert_eq!(
maybe.parameters[0].type_hint_str().as_deref(),
Some("?string")
);
assert_eq!(maybe.return_type_str().as_deref(), Some("?int"));
let either = functions.iter().find(|f| f.name == "either").unwrap();
assert_eq!(
either.parameters[0].type_hint_str().as_deref(),
Some("string|int")
);
assert_eq!(either.return_type_str().as_deref(), Some("string|false"));
}
#[tokio::test]
async fn test_parse_functions_variadic_and_reference() {
let backend = create_test_backend();
let php = "<?php\nfunction gather(string ...$items): array { return $items; }\nfunction swap(int &$a, int &$b): void {}\n";
let functions = backend.parse_functions(php);
assert_eq!(functions.len(), 2);
let gather = functions.iter().find(|f| f.name == "gather").unwrap();
assert_eq!(gather.parameters.len(), 1);
assert!(gather.parameters[0].is_variadic);
assert!(!gather.parameters[0].is_reference);
assert!(!gather.parameters[0].is_required);
let swap = functions.iter().find(|f| f.name == "swap").unwrap();
assert_eq!(swap.parameters.len(), 2);
assert!(swap.parameters[0].is_reference);
assert!(!swap.parameters[0].is_variadic);
}
#[tokio::test]
async fn test_parse_functions_brace_delimited_namespace() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"namespace Foo\\Bar {\n",
" function helper(): void {}\n",
"}\n",
);
let functions = backend.parse_functions(php);
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].name, "helper");
assert_eq!(functions[0].namespace.as_deref(), Some("Foo\\Bar"));
}
#[tokio::test]
async fn test_parse_functions_empty_file() {
let backend = create_test_backend();
let php = "<?php\n// nothing here\n";
let functions = backend.parse_functions(php);
assert!(functions.is_empty(), "No functions in an empty file");
}
#[tokio::test]
async fn test_parse_functions_inside_function_exists_guard() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"\n",
"if (! function_exists('session')) {\n",
" /**\n",
" * Get / set the specified session value.\n",
" */\n",
" function session($key = null, $default = null)\n",
" {\n",
" if (is_null($key)) {\n",
" return app('session');\n",
" }\n",
" return app('session')->get($key, $default);\n",
" }\n",
"}\n",
);
let functions = backend.parse_functions(php);
assert_eq!(
functions.len(),
1,
"Should find function inside if-guard block"
);
assert_eq!(functions[0].name, "session");
assert_eq!(functions[0].parameters.len(), 2);
assert!(
functions[0].return_type.is_none(),
"session() has no return type hint"
);
}
#[tokio::test]
async fn test_parse_functions_multiple_function_exists_guards() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"\n",
"if (! function_exists('app')) {\n",
" function app(?string $abstract = null, array $parameters = []): mixed\n",
" {\n",
" return Container::getInstance();\n",
" }\n",
"}\n",
"\n",
"if (! function_exists('session')) {\n",
" function session($key = null, $default = null)\n",
" {\n",
" return app('session');\n",
" }\n",
"}\n",
"\n",
"if (! function_exists('config')) {\n",
" function config($key = null, $default = null): mixed\n",
" {\n",
" return null;\n",
" }\n",
"}\n",
);
let functions = backend.parse_functions(php);
assert_eq!(
functions.len(),
3,
"Should find all 3 functions inside separate if-guards"
);
let names: Vec<&str> = functions.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"app"), "Should find app()");
assert!(names.contains(&"session"), "Should find session()");
assert!(names.contains(&"config"), "Should find config()");
let app = functions.iter().find(|f| f.name == "app").unwrap();
assert_eq!(app.return_type_str().as_deref(), Some("mixed"));
let config = functions.iter().find(|f| f.name == "config").unwrap();
assert_eq!(config.return_type_str().as_deref(), Some("mixed"));
}
#[tokio::test]
async fn test_parse_functions_inside_namespace_with_function_exists_guard() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"namespace Illuminate\\Support;\n",
"\n",
"if (! function_exists('Illuminate\\Support\\enum_value')) {\n",
" function enum_value($value): mixed\n",
" {\n",
" return $value;\n",
" }\n",
"}\n",
);
let functions = backend.parse_functions(php);
assert_eq!(
functions.len(),
1,
"Should find function inside if-guard within namespace"
);
assert_eq!(functions[0].name, "enum_value");
assert_eq!(
functions[0].namespace.as_deref(),
Some("Illuminate\\Support"),
"Should preserve namespace context"
);
assert_eq!(functions[0].return_type_str().as_deref(), Some("mixed"));
}
#[tokio::test]
async fn test_parse_functions_mixed_guarded_and_unguarded() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"\n",
"function always_defined(): void {}\n",
"\n",
"if (! function_exists('maybe_defined')) {\n",
" function maybe_defined(): string { return ''; }\n",
"}\n",
"\n",
"function also_always(): int { return 0; }\n",
);
let functions = backend.parse_functions(php);
assert_eq!(
functions.len(),
3,
"Should find both guarded and unguarded functions"
);
let names: Vec<&str> = functions.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"always_defined"));
assert!(names.contains(&"maybe_defined"));
assert!(names.contains(&"also_always"));
}
#[tokio::test]
async fn test_parse_php_extracts_backed_enum_cases_as_constants() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"enum CustomerAvailabilityStatus: int\n",
"{\n",
" case CUSTOMER_NOT_IN_AUDIENCE = -1;\n",
" case AVAILABLE_TO_CUSTOMER = 0;\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1, "Should parse the enum as a class-like");
assert_eq!(classes[0].name, "CustomerAvailabilityStatus");
assert_eq!(
classes[0].constants.len(),
2,
"Enum cases should be extracted as constants"
);
let case0 = &classes[0].constants[0];
assert_eq!(case0.name, "CUSTOMER_NOT_IN_AUDIENCE");
assert_eq!(
case0.visibility,
phpantom_lsp::types::Visibility::Public,
"Enum cases are always public"
);
let case1 = &classes[0].constants[1];
assert_eq!(case1.name, "AVAILABLE_TO_CUSTOMER");
}
#[tokio::test]
async fn test_parse_php_extracts_unit_enum_cases() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"enum Color\n",
"{\n",
" case Red;\n",
" case Green;\n",
" case Blue;\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "Color");
assert_eq!(
classes[0].constants.len(),
3,
"Unit enum cases should be extracted as constants"
);
let names: Vec<&str> = classes[0]
.constants
.iter()
.map(|c| c.name.as_str())
.collect();
assert_eq!(names, vec!["Red", "Green", "Blue"]);
}
#[tokio::test]
async fn test_parse_php_extracts_enum_with_methods() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"enum Suit: string\n",
"{\n",
" case Hearts = 'H';\n",
" case Diamonds = 'D';\n",
" case Clubs = 'C';\n",
" case Spades = 'S';\n",
"\n",
" public function color(): string\n",
" {\n",
" return match($this) {\n",
" Suit::Hearts, Suit::Diamonds => 'red',\n",
" Suit::Clubs, Suit::Spades => 'black',\n",
" };\n",
" }\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "Suit");
assert_eq!(classes[0].constants.len(), 4, "Should have 4 enum cases");
assert_eq!(classes[0].methods.len(), 1, "Should have 1 method");
assert_eq!(classes[0].methods[0].name, "color");
assert_eq!(
classes[0].methods[0].return_type_str().as_deref(),
Some("string")
);
}
#[tokio::test]
async fn test_parse_php_extracts_enum_with_constants_and_cases() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"enum Status: int\n",
"{\n",
" const DEFAULT_STATUS = 0;\n",
" case Active = 1;\n",
" case Inactive = 2;\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "Status");
assert_eq!(
classes[0].constants.len(),
3,
"Should have 1 real constant + 2 enum cases"
);
let names: Vec<&str> = classes[0]
.constants
.iter()
.map(|c| c.name.as_str())
.collect();
assert!(names.contains(&"DEFAULT_STATUS"));
assert!(names.contains(&"Active"));
assert!(names.contains(&"Inactive"));
}
#[tokio::test]
async fn test_parse_php_extracts_enum_inside_namespace() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"namespace App\\Enums;\n",
"\n",
"enum Direction\n",
"{\n",
" case Up;\n",
" case Down;\n",
" case Left;\n",
" case Right;\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "Direction");
assert_eq!(classes[0].constants.len(), 4);
}
#[tokio::test]
async fn test_parse_php_unit_enum_has_implicit_unit_enum_interface() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"enum Color\n",
"{\n",
" case Red;\n",
" case Green;\n",
" case Blue;\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "Color");
assert!(
classes[0].used_traits.iter().any(|t| t == "\\UnitEnum"),
"Unit enum should implicitly implement \\UnitEnum, got used_traits: {:?}",
classes[0].used_traits
);
assert!(
!classes[0].used_traits.iter().any(|t| t == "\\BackedEnum"),
"Unit enum should NOT implement \\BackedEnum, got used_traits: {:?}",
classes[0].used_traits
);
}
#[tokio::test]
async fn test_parse_php_backed_int_enum_has_implicit_backed_enum_interface() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"enum Priority: int\n",
"{\n",
" case Low = 0;\n",
" case High = 1;\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "Priority");
assert!(
classes[0].used_traits.iter().any(|t| t == "\\BackedEnum"),
"Backed int enum should implicitly implement \\BackedEnum, got used_traits: {:?}",
classes[0].used_traits
);
assert!(
!classes[0].used_traits.iter().any(|t| t == "\\UnitEnum"),
"Backed enum should NOT implement \\UnitEnum, got used_traits: {:?}",
classes[0].used_traits
);
}
#[tokio::test]
async fn test_parse_php_backed_string_enum_has_implicit_backed_enum_interface() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"enum Suit: string\n",
"{\n",
" case Hearts = 'H';\n",
" case Spades = 'S';\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "Suit");
assert!(
classes[0].used_traits.iter().any(|t| t == "\\BackedEnum"),
"Backed string enum should implicitly implement \\BackedEnum, got used_traits: {:?}",
classes[0].used_traits
);
}
#[tokio::test]
async fn test_parse_php_namespaced_enum_implicit_interface_is_not_namespace_prefixed() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"namespace App\\Enums;\n",
"\n",
"enum Mode\n",
"{\n",
" case Automatic;\n",
" case Manual;\n",
"}\n",
);
let mut classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "Mode");
assert!(
classes[0].used_traits.iter().any(|t| t == "\\UnitEnum"),
"Namespaced unit enum should have \\UnitEnum before resolution, got: {:?}",
classes[0].used_traits
);
let use_map = std::collections::HashMap::new();
let namespace = Some("App\\Enums".to_string());
phpantom_lsp::Backend::resolve_parent_class_names(&mut classes, &use_map, &namespace);
assert!(
classes[0].used_traits.iter().any(|t| t == "UnitEnum"),
"After resolution, should be bare UnitEnum (canonical FQN), got: {:?}",
classes[0].used_traits
);
assert!(
!classes[0]
.used_traits
.iter()
.any(|t| t == "App\\Enums\\UnitEnum"),
"Should NOT be namespace-prefixed as App\\Enums\\UnitEnum, got: {:?}",
classes[0].used_traits
);
}
#[tokio::test]
async fn test_parse_php_enum_with_trait_also_has_implicit_interface() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"trait HasDescription {\n",
" public function describe(): string { return 'desc'; }\n",
"}\n",
"\n",
"enum Status: int\n",
"{\n",
" use HasDescription;\n",
"\n",
" case Active = 1;\n",
" case Inactive = 0;\n",
"}\n",
);
let classes = backend.parse_php(php);
let enum_info = classes.iter().find(|c| c.name == "Status").unwrap();
assert!(
enum_info.used_traits.iter().any(|t| t == "HasDescription"),
"Should include the explicit trait, got: {:?}",
enum_info.used_traits
);
assert!(
enum_info.used_traits.iter().any(|t| t == "\\BackedEnum"),
"Should include implicit \\BackedEnum, got: {:?}",
enum_info.used_traits
);
}
#[tokio::test]
async fn test_parse_defines_single_quoted() {
let backend = create_test_backend();
let defines = backend.parse_defines("<?php\ndefine('MY_CONST', 42);\n");
let names: Vec<&str> = defines.iter().map(|(n, _, _)| n.as_str()).collect();
assert_eq!(names, vec!["MY_CONST"]);
assert!(defines[0].1 > 0, "define keyword offset should be non-zero");
assert_eq!(defines[0].2.as_deref(), Some("42"));
}
#[tokio::test]
async fn test_parse_defines_double_quoted() {
let backend = create_test_backend();
let defines = backend.parse_defines("<?php\ndefine(\"MY_CONST\", 'hello');\n");
let names: Vec<&str> = defines.iter().map(|(n, _, _)| n.as_str()).collect();
assert_eq!(names, vec!["MY_CONST"]);
}
#[tokio::test]
async fn test_parse_defines_multiple() {
let backend = create_test_backend();
let content = concat!(
"<?php\n",
"define('PHP_EOL', \"\\n\");\n",
"define('PHP_INT_MAX', 9223372036854775807);\n",
"define('SORT_ASC', 4);\n",
);
let defines = backend.parse_defines(content);
let names: Vec<&str> = defines.iter().map(|(n, _, _)| n.as_str()).collect();
assert_eq!(names, vec!["PHP_EOL", "PHP_INT_MAX", "SORT_ASC"]);
}
#[tokio::test]
async fn test_parse_defines_with_third_argument() {
let backend = create_test_backend();
let defines = backend.parse_defines("<?php\ndefine('__DIR__', '', true);\n");
let names: Vec<&str> = defines.iter().map(|(n, _, _)| n.as_str()).collect();
assert_eq!(names, vec!["__DIR__"]);
}
#[tokio::test]
async fn test_parse_defines_skips_non_define_calls() {
let backend = create_test_backend();
let content = concat!(
"<?php\n",
"some_define('NOT_A_CONST', 1);\n",
"user_define('ALSO_NOT', 2);\n",
"define('REAL_CONST', 3);\n",
);
let defines = backend.parse_defines(content);
let names: Vec<&str> = defines.iter().map(|(n, _, _)| n.as_str()).collect();
assert_eq!(names, vec!["REAL_CONST"]);
}
#[tokio::test]
async fn test_parse_defines_skips_dynamic_names() {
let backend = create_test_backend();
let content = concat!(
"<?php\n",
"define($varName, 42);\n",
"define('GOOD_CONST', 1);\n",
);
let defines = backend.parse_defines(content);
let names: Vec<&str> = defines.iter().map(|(n, _, _)| n.as_str()).collect();
assert_eq!(names, vec!["GOOD_CONST"]);
}
#[tokio::test]
async fn test_parse_defines_empty_file() {
let backend = create_test_backend();
let defines = backend.parse_defines("<?php\n");
assert!(defines.is_empty());
}
#[tokio::test]
async fn test_parse_defines_no_defines() {
let backend = create_test_backend();
let defines = backend.parse_defines("<?php\necho 'hello';\nfunction foo() {}\n");
assert!(defines.is_empty());
}
#[tokio::test]
async fn test_parse_defines_inside_if_guard() {
let backend = create_test_backend();
let content = concat!(
"<?php\n",
"if (!defined('MY_CONST')) {\n",
" define('MY_CONST', 'value');\n",
"}\n",
);
let defines = backend.parse_defines(content);
let names: Vec<&str> = defines.iter().map(|(n, _, _)| n.as_str()).collect();
assert_eq!(names, vec!["MY_CONST"]);
}
#[tokio::test]
async fn test_parse_defines_inside_namespace() {
let backend = create_test_backend();
let content = concat!(
"<?php\n",
"namespace App;\n",
"define('APP_VERSION', '2.0');\n",
);
let defines = backend.parse_defines(content);
let names: Vec<&str> = defines.iter().map(|(n, _, _)| n.as_str()).collect();
assert_eq!(names, vec!["APP_VERSION"]);
}
#[tokio::test]
async fn test_parse_defines_inside_block() {
let backend = create_test_backend();
let content = concat!("<?php\n", "{\n", " define('BLOCK_CONST', 1);\n", "}\n",);
let defines = backend.parse_defines(content);
let names: Vec<&str> = defines.iter().map(|(n, _, _)| n.as_str()).collect();
assert_eq!(names, vec!["BLOCK_CONST"]);
}
#[tokio::test]
async fn test_parse_defines_mixed_with_classes_and_functions() {
let backend = create_test_backend();
let content = concat!(
"<?php\n",
"define('VERSION', '1.0');\n",
"class Foo { public function bar() {} }\n",
"define('DEBUG', true);\n",
"function helper() {}\n",
"define('MAX_RETRIES', 3);\n",
);
let defines = backend.parse_defines(content);
let names: Vec<&str> = defines.iter().map(|(n, _, _)| n.as_str()).collect();
assert_eq!(names, vec!["VERSION", "DEBUG", "MAX_RETRIES"]);
}
#[tokio::test]
async fn test_parse_defines_ignores_method_calls_named_define() {
let backend = create_test_backend();
let content = concat!(
"<?php\n",
"$obj->define('NOT_A_CONST', 1);\n",
"define('REAL_CONST', 2);\n",
);
let defines = backend.parse_defines(content);
let names: Vec<&str> = defines.iter().map(|(n, _, _)| n.as_str()).collect();
assert_eq!(names, vec!["REAL_CONST"]);
}
#[tokio::test]
async fn test_deprecated_attribute_on_class_bare() {
let backend = create_test_backend();
let php = concat!("<?php\n", "#[Deprecated]\n", "class OldHelper {}\n",);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
assert!(
classes[0].deprecation_message.is_some(),
"Bare #[Deprecated] should set deprecation_message"
);
}
#[tokio::test]
async fn test_deprecated_attribute_on_class_with_reason_and_since() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"#[Deprecated(reason: 'Use NewApi instead', since: '8.2')]\n",
"class OldApi {}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let msg = classes[0].deprecation_message.as_deref().unwrap();
assert!(
msg.contains("Use NewApi instead"),
"Expected reason in message, got: {msg}"
);
assert!(
msg.contains("since PHP 8.2"),
"Expected since in message, got: {msg}"
);
}
#[tokio::test]
async fn test_deprecated_attribute_on_class_positional_reason() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"#[Deprecated('Use NewHelper instead')]\n",
"class OldHelper {}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let msg = classes[0].deprecation_message.as_deref().unwrap();
assert!(
msg.contains("Use NewHelper instead"),
"Expected positional reason in message, got: {msg}"
);
}
#[tokio::test]
async fn test_deprecated_attribute_on_method_with_reason() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Mailer {\n",
" #[Deprecated(reason: 'Use sendAsync() instead', since: '8.1')]\n",
" public function sendLegacy(): void {}\n",
"}\n",
);
let classes = backend.parse_php(php);
assert_eq!(classes.len(), 1);
let method = classes[0]
.methods
.iter()
.find(|m| m.name == "sendLegacy")
.unwrap();
let msg = method.deprecation_message.as_deref().unwrap();
assert!(
msg.contains("Use sendAsync() instead"),
"Expected reason in method deprecation, got: {msg}"
);
assert!(
msg.contains("since PHP 8.1"),
"Expected since in method deprecation, got: {msg}"
);
}
#[tokio::test]
async fn test_deprecated_attribute_on_method_bare() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Mailer {\n",
" #[Deprecated]\n",
" public function sendLegacy(): void {}\n",
"}\n",
);
let classes = backend.parse_php(php);
let method = classes[0]
.methods
.iter()
.find(|m| m.name == "sendLegacy")
.unwrap();
assert!(
method.deprecation_message.is_some(),
"Bare #[Deprecated] on method should set deprecation_message"
);
}
#[tokio::test]
async fn test_deprecated_attribute_on_property_with_reason() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Doc {\n",
" #[Deprecated('The property is deprecated', since: '8.4')]\n",
" public string $encoding = 'UTF-8';\n",
"}\n",
);
let classes = backend.parse_php(php);
let prop = classes[0]
.properties
.iter()
.find(|p| p.name == "encoding")
.unwrap();
let msg = prop.deprecation_message.as_deref().unwrap();
assert!(
msg.contains("The property is deprecated"),
"Expected reason in property deprecation, got: {msg}"
);
assert!(
msg.contains("since PHP 8.4"),
"Expected since in property deprecation, got: {msg}"
);
}
#[tokio::test]
async fn test_deprecated_attribute_on_property_bare() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Doc {\n",
" #[Deprecated]\n",
" public string $config = '';\n",
"}\n",
);
let classes = backend.parse_php(php);
let prop = classes[0]
.properties
.iter()
.find(|p| p.name == "config")
.unwrap();
assert!(
prop.deprecation_message.is_some(),
"Bare #[Deprecated] on property should set deprecation_message"
);
}
#[tokio::test]
async fn test_deprecated_attribute_on_constant() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class PDO {\n",
" #[Deprecated(reason: 'Use ATTR_EMULATE_PREPARES instead')]\n",
" const ATTR_OLD = 1;\n",
"}\n",
);
let classes = backend.parse_php(php);
let constant = classes[0]
.constants
.iter()
.find(|c| c.name == "ATTR_OLD")
.unwrap();
let msg = constant.deprecation_message.as_deref().unwrap();
assert!(
msg.contains("Use ATTR_EMULATE_PREPARES instead"),
"Expected reason in constant deprecation, got: {msg}"
);
}
#[tokio::test]
async fn test_deprecated_attribute_on_constant_bare() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Config {\n",
" #[Deprecated]\n",
" const OLD_MODE = 0;\n",
"}\n",
);
let classes = backend.parse_php(php);
let constant = classes[0]
.constants
.iter()
.find(|c| c.name == "OLD_MODE")
.unwrap();
assert!(
constant.deprecation_message.is_some(),
"Bare #[Deprecated] on constant should set deprecation_message"
);
}
#[tokio::test]
async fn test_deprecated_attribute_on_function() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"#[Deprecated(reason: 'Use new_helper() instead', since: '7.4')]\n",
"function old_helper(): void {}\n",
);
let functions = backend.parse_functions(php);
let func = functions.iter().find(|f| f.name == "old_helper").unwrap();
let msg = func.deprecation_message.as_deref().unwrap();
assert!(
msg.contains("Use new_helper() instead"),
"Expected reason in function deprecation, got: {msg}"
);
assert!(
msg.contains("since PHP 7.4"),
"Expected since in function deprecation, got: {msg}"
);
}
#[tokio::test]
async fn test_deprecated_attribute_on_function_bare() {
let backend = create_test_backend();
let php = concat!("<?php\n", "#[Deprecated]\n", "function old_fn(): void {}\n",);
let functions = backend.parse_functions(php);
let func = functions.iter().find(|f| f.name == "old_fn").unwrap();
assert!(
func.deprecation_message.is_some(),
"Bare #[Deprecated] on function should set deprecation_message"
);
}
#[tokio::test]
async fn test_docblock_deprecated_takes_priority_over_attribute() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Mailer {\n",
" /**\n",
" * @deprecated Use sendModern() instead.\n",
" */\n",
" #[Deprecated(reason: 'Attribute message')]\n",
" public function sendLegacy(): void {}\n",
"}\n",
);
let classes = backend.parse_php(php);
let method = classes[0]
.methods
.iter()
.find(|m| m.name == "sendLegacy")
.unwrap();
let msg = method.deprecation_message.as_deref().unwrap();
assert!(
msg.contains("Use sendModern() instead"),
"Docblock @deprecated should take priority, got: {msg}"
);
assert!(
!msg.contains("Attribute message"),
"Attribute message should not appear when docblock has @deprecated, got: {msg}"
);
}
#[tokio::test]
async fn test_deprecated_attribute_since_only() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Config {\n",
" #[Deprecated(since: '7.4')]\n",
" const OLD_MODE = 0;\n",
"}\n",
);
let classes = backend.parse_php(php);
let constant = classes[0]
.constants
.iter()
.find(|c| c.name == "OLD_MODE")
.unwrap();
let msg = constant.deprecation_message.as_deref().unwrap();
assert_eq!(msg, "since PHP 7.4");
}
#[tokio::test]
async fn test_deprecated_attribute_on_interface() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"#[Deprecated(reason: 'Use NewInterface instead')]\n",
"interface OldInterface {\n",
" public function doThing(): void;\n",
"}\n",
);
let classes = backend.parse_php(php);
let iface = classes.iter().find(|c| c.name == "OldInterface").unwrap();
let msg = iface.deprecation_message.as_deref().unwrap();
assert!(
msg.contains("Use NewInterface instead"),
"Expected reason in interface deprecation, got: {msg}"
);
}
#[tokio::test]
async fn test_deprecated_attribute_on_enum() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"#[Deprecated(reason: 'Use StatusV2 instead')]\n",
"enum Status {\n",
" case Active;\n",
" case Inactive;\n",
"}\n",
);
let classes = backend.parse_php(php);
let enm = classes.iter().find(|c| c.name == "Status").unwrap();
let msg = enm.deprecation_message.as_deref().unwrap();
assert!(
msg.contains("Use StatusV2 instead"),
"Expected reason in enum deprecation, got: {msg}"
);
}
#[tokio::test]
async fn test_non_deprecated_elements_unaffected() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Service {\n",
" public function handle(): void {}\n",
" public string $name = '';\n",
" const VERSION = 1;\n",
"}\n",
);
let classes = backend.parse_php(php);
let method = classes[0]
.methods
.iter()
.find(|m| m.name == "handle")
.unwrap();
assert!(method.deprecation_message.is_none());
let prop = classes[0]
.properties
.iter()
.find(|p| p.name == "name")
.unwrap();
assert!(prop.deprecation_message.is_none());
let constant = classes[0]
.constants
.iter()
.find(|c| c.name == "VERSION")
.unwrap();
assert!(constant.deprecation_message.is_none());
}
#[tokio::test]
async fn test_deprecated_attribute_native_php84_message_named_arg() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"#[\\Deprecated(message: 'Use safe_replacement() instead', since: '1.5')]\n",
"function unsafe_function(): void {}\n",
);
let functions = backend.parse_functions(php);
let func = functions
.iter()
.find(|f| f.name == "unsafe_function")
.unwrap();
let msg = func.deprecation_message.as_deref().unwrap();
assert!(
msg.contains("Use safe_replacement() instead"),
"Expected message in function deprecation, got: {msg}"
);
assert!(
msg.contains("since PHP 1.5"),
"Expected since in function deprecation, got: {msg}"
);
}
#[tokio::test]
async fn test_deprecated_attribute_native_php84_fqn_on_method() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Service {\n",
" #[\\Deprecated(message: 'Use processV2() instead', since: '8.4')]\n",
" public function process(): void {}\n",
"}\n",
);
let classes = backend.parse_php(php);
let method = classes[0]
.methods
.iter()
.find(|m| m.name == "process")
.unwrap();
let msg = method.deprecation_message.as_deref().unwrap();
assert!(
msg.contains("Use processV2() instead"),
"Expected message in method deprecation, got: {msg}"
);
assert!(
msg.contains("since PHP 8.4"),
"Expected since in method deprecation, got: {msg}"
);
}
#[tokio::test]
async fn test_deprecated_attribute_native_php84_message_only() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Config {\n",
" #[\\Deprecated(message: 'Use NEW_LIMIT instead')]\n",
" const OLD_LIMIT = 100;\n",
"}\n",
);
let classes = backend.parse_php(php);
let constant = classes[0]
.constants
.iter()
.find(|c| c.name == "OLD_LIMIT")
.unwrap();
let msg = constant.deprecation_message.as_deref().unwrap();
assert_eq!(msg, "Use NEW_LIMIT instead");
}
#[tokio::test]
async fn test_deprecated_attribute_fqn_without_backslash_prefix() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Demo {\n",
" #[Deprecated(reason: 'JetBrains style')]\n",
" public function jetbrainsStyle(): void {}\n",
"\n",
" #[\\Deprecated(message: 'Native PHP style')]\n",
" public function nativeStyle(): void {}\n",
"}\n",
);
let classes = backend.parse_php(php);
let jb = classes[0]
.methods
.iter()
.find(|m| m.name == "jetbrainsStyle")
.unwrap();
assert!(
jb.deprecation_message
.as_deref()
.unwrap()
.contains("JetBrains style"),
"JetBrains-style #[Deprecated(reason:)] should work"
);
let native = classes[0]
.methods
.iter()
.find(|m| m.name == "nativeStyle")
.unwrap();
assert!(
native
.deprecation_message
.as_deref()
.unwrap()
.contains("Native PHP style"),
"Native-style #[\\Deprecated(message:)] should work"
);
}
#[tokio::test]
async fn test_custom_namespaced_deprecated_attribute_does_not_trigger() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"namespace App;\n",
"\n",
"#[\\Test\\Deprecated(reason: 'Not a real deprecation')]\n",
"function still_fine(): void {}\n",
);
let functions = backend.parse_functions(php);
let func = functions.iter().find(|f| f.name == "still_fine").unwrap();
assert!(
func.deprecation_message.is_none(),
"#[\\Test\\Deprecated] should NOT trigger deprecation, got: {:?}",
func.deprecation_message
);
}
#[tokio::test]
async fn test_custom_namespaced_deprecated_attribute_on_method_does_not_trigger() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Service {\n",
" #[\\App\\Attributes\\Deprecated(reason: 'Custom attribute')]\n",
" public function process(): void {}\n",
"}\n",
);
let classes = backend.parse_php(php);
let method = classes[0]
.methods
.iter()
.find(|m| m.name == "process")
.unwrap();
assert!(
method.deprecation_message.is_none(),
"#[\\App\\Attributes\\Deprecated] should NOT trigger deprecation, got: {:?}",
method.deprecation_message
);
}
#[tokio::test]
async fn test_custom_namespaced_deprecated_attribute_on_class_does_not_trigger() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"#[\\Vendor\\Deprecated]\n",
"class OldService {}\n",
);
let classes = backend.parse_php(php);
assert!(
classes[0].deprecation_message.is_none(),
"#[\\Vendor\\Deprecated] should NOT trigger class deprecation, got: {:?}",
classes[0].deprecation_message
);
}
#[tokio::test]
async fn test_legitimate_deprecated_attributes_still_work() {
let backend = create_test_backend();
let php = concat!(
"<?php\n",
"class Demo {\n",
" #[\\Deprecated(message: 'native')]\n",
" public function nativeAttr(): void {}\n",
"\n",
" #[\\JetBrains\\PhpStorm\\Deprecated(reason: 'jetbrains')]\n",
" public function jbAttr(): void {}\n",
"}\n",
);
let classes = backend.parse_php(php);
let native = classes[0]
.methods
.iter()
.find(|m| m.name == "nativeAttr")
.unwrap();
assert!(
native.deprecation_message.is_some(),
"#[\\Deprecated] should trigger deprecation"
);
let jb = classes[0]
.methods
.iter()
.find(|m| m.name == "jbAttr")
.unwrap();
assert!(
jb.deprecation_message.is_some(),
"#[\\JetBrains\\PhpStorm\\Deprecated] should trigger deprecation"
);
}