use phpantom_lsp::docblock::*;
use phpantom_lsp::php_type::PhpType;
use phpantom_lsp::types::*;
#[test]
fn method_tag_simple() {
let doc = "/** @method MockInterface mock(string $abstract) */";
let methods = extract_method_tags(doc);
assert_eq!(methods.len(), 1);
assert_eq!(methods[0].name, "mock");
assert_eq!(
methods[0].return_type_str().as_deref(),
Some("MockInterface")
);
assert!(!methods[0].is_static);
assert_eq!(methods[0].parameters.len(), 1);
assert_eq!(methods[0].parameters[0].name, "$abstract");
assert_eq!(
methods[0].parameters[0].type_hint_str().as_deref(),
Some("string")
);
assert!(methods[0].parameters[0].is_required);
}
#[test]
fn method_tag_static() {
let doc = "/** @method static Decimal getAmountUntilBonusCashIsTriggered() */";
let methods = extract_method_tags(doc);
assert_eq!(methods.len(), 1);
assert_eq!(methods[0].name, "getAmountUntilBonusCashIsTriggered");
assert_eq!(methods[0].return_type_str().as_deref(), Some("Decimal"));
assert!(methods[0].is_static);
assert!(methods[0].parameters.is_empty());
}
#[test]
fn method_tag_no_return_type() {
let doc = "/** @method assertDatabaseHas(string $table, array<string, mixed> $data, string $connection = null) */";
let methods = extract_method_tags(doc);
assert_eq!(methods.len(), 1);
assert_eq!(methods[0].name, "assertDatabaseHas");
assert!(methods[0].return_type.is_none());
assert_eq!(methods[0].parameters.len(), 3);
assert_eq!(methods[0].parameters[0].name, "$table");
assert_eq!(
methods[0].parameters[0].type_hint_str().as_deref(),
Some("string")
);
assert!(methods[0].parameters[0].is_required);
assert_eq!(methods[0].parameters[1].name, "$data");
assert_eq!(
methods[0].parameters[1].type_hint_str().as_deref(),
Some("array<string, mixed>")
);
assert!(methods[0].parameters[1].is_required);
assert_eq!(methods[0].parameters[2].name, "$connection");
assert_eq!(
methods[0].parameters[2].type_hint_str().as_deref(),
Some("string")
);
assert!(!methods[0].parameters[2].is_required);
}
#[test]
fn method_tag_fqn_return_type() {
let doc = "/** @method \\Mockery\\MockInterface mock(string $abstract) */";
let methods = extract_method_tags(doc);
assert_eq!(methods.len(), 1);
assert_eq!(
methods[0].return_type_str().as_deref(),
Some("\\Mockery\\MockInterface")
);
}
#[test]
fn method_tag_callable_param() {
let doc = "/** @method MockInterface mock(string $abstract, callable():mixed $mockDefinition = null) */";
let methods = extract_method_tags(doc);
assert_eq!(methods.len(), 1);
assert_eq!(methods[0].parameters.len(), 2);
assert_eq!(methods[0].parameters[1].name, "$mockDefinition");
assert!(!methods[0].parameters[1].is_required);
}
#[test]
fn method_tag_multiple() {
let doc = concat!(
"/**\n",
" * @method \\Mockery\\MockInterface mock(string $abstract, callable():mixed $mockDefinition = null)\n",
" * @method assertDatabaseHas(string $table, array<string, mixed> $data, string $connection = null)\n",
" * @method assertDatabaseMissing(string $table, array<string, mixed> $data, string $connection = null)\n",
" * @method static Decimal getAmountUntilBonusCashIsTriggered()\n",
" */",
);
let methods = extract_method_tags(doc);
assert_eq!(methods.len(), 4);
assert_eq!(methods[0].name, "mock");
assert!(!methods[0].is_static);
assert_eq!(methods[1].name, "assertDatabaseHas");
assert!(!methods[1].is_static);
assert_eq!(methods[2].name, "assertDatabaseMissing");
assert!(!methods[2].is_static);
assert_eq!(methods[3].name, "getAmountUntilBonusCashIsTriggered");
assert!(methods[3].is_static);
}
#[test]
fn method_tag_no_params() {
let doc = "/** @method string getName() */";
let methods = extract_method_tags(doc);
assert_eq!(methods.len(), 1);
assert_eq!(methods[0].name, "getName");
assert_eq!(methods[0].return_type_str().as_deref(), Some("string"));
assert!(methods[0].parameters.is_empty());
}
#[test]
fn method_tag_nullable_return() {
let doc = "/** @method ?User findUser(int $id) */";
let methods = extract_method_tags(doc);
assert_eq!(methods.len(), 1);
assert_eq!(methods[0].return_type_str().as_deref(), Some("?User"));
}
#[test]
fn method_tag_none_when_missing() {
let doc = "/** @property string $name */";
let methods = extract_method_tags(doc);
assert!(methods.is_empty());
}
#[test]
fn method_tag_variadic_param() {
let doc = "/** @method void addItems(string ...$items) */";
let methods = extract_method_tags(doc);
assert_eq!(methods.len(), 1);
assert_eq!(methods[0].parameters.len(), 1);
assert!(methods[0].parameters[0].is_variadic);
assert!(!methods[0].parameters[0].is_required);
}
#[test]
fn method_tag_name_matches_type_keyword() {
let doc =
"/** @method static string string(string $key, \\Closure|string|null $default = null) */";
let methods = extract_method_tags(doc);
assert_eq!(methods.len(), 1);
assert_eq!(methods[0].name, "string");
assert_eq!(methods[0].return_type_str().as_deref(), Some("string"));
assert!(methods[0].is_static);
assert_eq!(methods[0].parameters.len(), 2);
assert_eq!(methods[0].parameters[0].name, "$key");
assert_eq!(
methods[0].parameters[0].type_hint_str().as_deref(),
Some("string")
);
}
#[test]
fn property_tag_simple() {
let doc = "/** @property Session $session */";
let props = extract_property_tags(doc);
assert_eq!(
props,
vec![("session".to_string(), Some(PhpType::parse("Session")))]
);
}
#[test]
fn property_tag_nullable() {
let doc = "/** @property ?int $count */";
let props = extract_property_tags(doc);
assert_eq!(
props,
vec![("count".to_string(), Some(PhpType::parse("?int")))]
);
}
#[test]
fn property_tag_union_with_null() {
let doc = "/** @property null|int $latest_id */";
let props = extract_property_tags(doc);
assert_eq!(
props,
vec![("latest_id".to_string(), Some(PhpType::parse("null|int")))]
);
}
#[test]
fn property_tag_fqn() {
let doc = "/** @property \\App\\Models\\User $user */";
let props = extract_property_tags(doc);
assert_eq!(
props,
vec![(
"user".to_string(),
Some(PhpType::parse("\\App\\Models\\User"))
)]
);
}
#[test]
fn property_tag_multiple() {
let doc = concat!(
"/**\n",
" * @property null|int $latest_subscription_agreement_id\n",
" * @property UserMobileVerificationState $mobile_verification_state\n",
" */",
);
let props = extract_property_tags(doc);
assert_eq!(props.len(), 2);
assert_eq!(
props[0],
(
"latest_subscription_agreement_id".to_string(),
Some(PhpType::parse("null|int"))
)
);
assert_eq!(
props[1],
(
"mobile_verification_state".to_string(),
Some(PhpType::parse("UserMobileVerificationState"))
)
);
}
#[test]
fn property_tag_read_write_variants() {
let doc = concat!(
"/**\n",
" * @property-read string $name\n",
" * @property-write int $age\n",
" */",
);
let props = extract_property_tags(doc);
assert_eq!(props.len(), 2);
assert_eq!(
props[0],
("name".to_string(), Some(PhpType::parse("string")))
);
assert_eq!(props[1], ("age".to_string(), Some(PhpType::parse("int"))));
}
#[test]
fn property_tag_no_type() {
let doc = "/** @property $thing */";
let props = extract_property_tags(doc);
assert_eq!(props, vec![("thing".to_string(), None)]);
}
#[test]
fn property_tag_generic_preserved() {
let doc = "/** @property Collection<int, Model> $items */";
let props = extract_property_tags(doc);
assert_eq!(
props,
vec![(
"items".to_string(),
Some(PhpType::parse("Collection<int, Model>"))
)]
);
}
#[test]
fn property_tag_none_when_missing() {
let doc = "/** @return Foo */";
let props = extract_property_tags(doc);
assert!(props.is_empty());
}
#[test]
fn return_type_conditional_is_skipped() {
let doc = concat!(
"/**\n",
" * @return ($abstract is class-string<TClass> ? TClass : mixed)\n",
" */",
);
assert_eq!(extract_return_type(doc), None);
}
#[test]
fn return_type_simple() {
let doc = "/** @return Application */";
assert_eq!(
extract_return_type(doc),
Some(PhpType::parse("Application"))
);
}
#[test]
fn return_type_fqn() {
let doc = "/** @return \\Illuminate\\Session\\Store */";
assert_eq!(
extract_return_type(doc),
Some(PhpType::parse("\\Illuminate\\Session\\Store"))
);
}
#[test]
fn return_type_nullable() {
let doc = "/** @return ?Application */";
assert_eq!(
extract_return_type(doc),
Some(PhpType::parse("?Application"))
);
}
#[test]
fn return_type_with_description() {
let doc = "/** @return Application The main app instance */";
assert_eq!(
extract_return_type(doc),
Some(PhpType::parse("Application"))
);
}
#[test]
fn return_type_multiline() {
let doc = concat!(
"/**\n",
" * Some method.\n",
" *\n",
" * @param string $key\n",
" * @return \\Illuminate\\Session\\Store\n",
" */",
);
assert_eq!(
extract_return_type(doc),
Some(PhpType::parse("\\Illuminate\\Session\\Store"))
);
}
#[test]
fn return_type_none_when_missing() {
let doc = "/** This is a docblock without a return tag */";
assert_eq!(extract_return_type(doc), None);
}
#[test]
fn return_type_nullable_union() {
let doc = "/** @return Application|null */";
assert_eq!(
extract_return_type(doc),
Some(PhpType::parse("Application|null"))
);
}
#[test]
fn return_type_generic_preserved() {
let doc = "/** @return Collection<int, Model> */";
assert_eq!(
extract_return_type(doc),
Some(PhpType::parse("Collection<int, Model>"))
);
}
#[test]
fn return_type_multiline_generic_simple() {
let doc = concat!(
"/**\n",
" * @return array<\n",
" * string,\n",
" * int\n",
" * >\n",
" */",
);
assert_eq!(
extract_return_type(doc),
Some(PhpType::parse("array<string, int>"))
);
}
#[test]
fn return_type_multiline_static_with_conditionals() {
let doc = concat!(
"/**\n",
" * @return static<\n",
" * ($groupBy is (array|string)\n",
" * ? array-key\n",
" * : TGroupKey),\n",
" * static<($preserveKeys is true ? TKey : int), TValue>\n",
" * >\n",
" */",
);
assert_eq!(
extract_return_type(doc),
Some(PhpType::parse(
"static<($groupBy is (array|string) ? array-key : TGroupKey), static<($preserveKeys is true ? TKey : int), TValue>>"
))
);
}
#[test]
fn return_type_multiline_nested_generics() {
let doc = concat!(
"/**\n",
" * @return Collection<\n",
" * int,\n",
" * Collection<string, User>\n",
" * >\n",
" */",
);
assert_eq!(
extract_return_type(doc),
Some(PhpType::parse("Collection<int, Collection<string, User>>"))
);
}
#[test]
fn return_type_multiline_brace_shape() {
let doc = concat!(
"/**\n",
" * @return array{\n",
" * name: string,\n",
" * age: int\n",
" * }\n",
" */",
);
assert_eq!(
extract_return_type(doc),
Some(PhpType::parse("array{name: string, age: int}"))
);
}
#[test]
fn return_type_unclosed_angle_recovers_base() {
let doc = concat!("/**\n", " * @return SomeType<\n", " */",);
let raw = extract_return_type(doc);
assert_eq!(raw, Some(PhpType::parse("SomeType")));
}
#[test]
fn return_type_unclosed_angle_static_recovers() {
let doc = concat!("/**\n", " * @return static<\n", " */",);
let raw = extract_return_type(doc);
assert_eq!(raw, Some(PhpType::parse("static")));
}
#[test]
fn effective_type_broken_docblock_falls_back_to_native() {
let native = PhpType::parse("Result");
let sanitised = sanitise_and_parse_docblock_type("<broken");
assert_eq!(
resolve_effective_type_typed(Some(&native), sanitised.as_ref()).map(|t| t.to_string()),
Some("Result".into()),
);
}
#[test]
fn effective_type_broken_docblock_recovers_base() {
let sanitised = sanitise_and_parse_docblock_type("Collection<int");
assert_eq!(
resolve_effective_type_typed(None, sanitised.as_ref()).map(|t| t.to_string()),
Some("Collection".into()),
);
}
#[test]
fn effective_type_balanced_docblock_unchanged() {
assert_eq!(
resolve_effective_type_typed(
Some(&PhpType::parse("array")),
Some(&PhpType::parse("Collection<int, User>"))
)
.map(|t| t.to_string()),
Some("Collection<int, User>".into()),
);
}
#[test]
fn var_type_simple() {
let doc = "/** @var Session */";
assert_eq!(extract_var_type(doc), Some(PhpType::parse("Session")));
}
#[test]
fn var_type_fqn() {
let doc = "/** @var \\App\\Models\\User */";
assert_eq!(
extract_var_type(doc),
Some(PhpType::parse("\\App\\Models\\User"))
);
}
#[test]
fn var_type_none_when_missing() {
let doc = "/** just a comment */";
assert_eq!(extract_var_type(doc), None);
}
#[test]
fn var_type_with_name_simple() {
let doc = "/** @var Session */";
assert_eq!(
extract_var_type_with_name(doc),
Some((PhpType::parse("Session"), None))
);
}
#[test]
fn var_type_with_name_has_var() {
let doc = "/** @var Session $sess */";
assert_eq!(
extract_var_type_with_name(doc),
Some((PhpType::parse("Session"), Some("$sess".into())))
);
}
#[test]
fn var_type_with_name_fqn() {
let doc = "/** @var \\App\\Models\\User $user */";
assert_eq!(
extract_var_type_with_name(doc),
Some((PhpType::parse("\\App\\Models\\User"), Some("$user".into())))
);
}
#[test]
fn var_type_with_name_no_var_tag() {
let doc = "/** just a comment */";
assert_eq!(extract_var_type_with_name(doc), None);
}
#[test]
fn var_type_with_name_description_not_var() {
let doc = "/** @var Session some description */";
assert_eq!(
extract_var_type_with_name(doc),
Some((PhpType::parse("Session"), None))
);
}
#[test]
fn var_type_with_name_generic_preserved() {
let doc = "/** @var Collection<int, User> $items */";
assert_eq!(
extract_var_type_with_name(doc),
Some((
PhpType::parse("Collection<int, User>"),
Some("$items".into())
))
);
}
#[test]
fn inline_var_docblock_simple() {
let content = "<?php\n/** @var Session */\n$var = mystery();\n";
let stmt_start = content.find("$var").unwrap();
assert_eq!(
find_inline_var_docblock(content, stmt_start),
Some((PhpType::parse("Session"), None))
);
}
#[test]
fn inline_var_docblock_with_var_name() {
let content = "<?php\n/** @var Session $var */\n$var = mystery();\n";
let stmt_start = content.find("$var =").unwrap();
assert_eq!(
find_inline_var_docblock(content, stmt_start),
Some((PhpType::parse("Session"), Some("$var".into())))
);
}
#[test]
fn inline_var_docblock_fqn() {
let content = "<?php\n/** @var \\App\\Models\\User */\n$u = get();\n";
let stmt_start = content.find("$u").unwrap();
assert_eq!(
find_inline_var_docblock(content, stmt_start),
Some((PhpType::parse("\\App\\Models\\User"), None))
);
}
#[test]
fn inline_var_docblock_no_docblock() {
let content = "<?php\n$var = mystery();\n";
let stmt_start = content.find("$var").unwrap();
assert_eq!(find_inline_var_docblock(content, stmt_start), None);
}
#[test]
fn inline_var_docblock_regular_comment_ignored() {
let content = "<?php\n/* @var Session */\n$var = mystery();\n";
let stmt_start = content.find("$var").unwrap();
assert_eq!(find_inline_var_docblock(content, stmt_start), None);
}
#[test]
fn inline_var_docblock_with_indentation() {
let content = "<?php\nclass A {\n public function f() {\n /** @var Session */\n $var = mystery();\n }\n}\n";
let stmt_start = content.find("$var").unwrap();
assert_eq!(
find_inline_var_docblock(content, stmt_start),
Some((PhpType::parse("Session"), None))
);
}
#[test]
fn override_object_with_class() {
assert!(should_override_type_typed(
&PhpType::parse("Session"),
&PhpType::parse("object")
));
}
#[test]
fn override_mixed_with_class() {
assert!(should_override_type_typed(
&PhpType::parse("Session"),
&PhpType::parse("mixed")
));
}
#[test]
fn override_class_with_subclass() {
assert!(should_override_type_typed(
&PhpType::parse("ConcreteSession"),
&PhpType::parse("SessionInterface")
));
}
#[test]
fn no_override_int_with_class() {
assert!(!should_override_type_typed(
&PhpType::parse("Session"),
&PhpType::parse("int")
));
}
#[test]
fn no_override_string_with_class() {
assert!(!should_override_type_typed(
&PhpType::parse("Session"),
&PhpType::parse("string")
));
}
#[test]
fn no_override_bool_with_class() {
assert!(!should_override_type_typed(
&PhpType::parse("Session"),
&PhpType::parse("bool")
));
}
#[test]
fn override_array_with_class() {
assert!(should_override_type_typed(
&PhpType::parse("Session"),
&PhpType::parse("array")
));
}
#[test]
fn override_array_with_generic_list() {
assert!(should_override_type_typed(
&PhpType::parse("list<User>"),
&PhpType::parse("array")
));
}
#[test]
fn override_array_with_generic_collection() {
assert!(should_override_type_typed(
&PhpType::parse("Collection<int, Order>"),
&PhpType::parse("array")
));
}
#[test]
fn override_iterable_with_class() {
assert!(should_override_type_typed(
&PhpType::parse("Collection<int, User>"),
&PhpType::parse("iterable")
));
}
#[test]
fn override_nullable_array_with_class() {
assert!(should_override_type_typed(
&PhpType::parse("list<User>"),
&PhpType::parse("?array")
));
}
#[test]
fn no_override_array_with_scalar_docblock() {
assert!(!should_override_type_typed(
&PhpType::parse("array"),
&PhpType::parse("array")
));
assert!(!should_override_type_typed(
&PhpType::parse("string"),
&PhpType::parse("string")
));
}
#[test]
fn override_array_with_generic_scalar_docblock() {
assert!(should_override_type_typed(
&PhpType::parse("array<string, mixed>"),
&PhpType::parse("array")
));
assert!(should_override_type_typed(
&PhpType::parse("array<int, User>"),
&PhpType::parse("array")
));
assert!(should_override_type_typed(
&PhpType::parse("iterable<string, Order>"),
&PhpType::parse("iterable")
));
}
#[test]
fn no_override_void_with_class() {
assert!(!should_override_type_typed(
&PhpType::parse("Session"),
&PhpType::parse("void")
));
}
#[test]
fn no_override_nullable_int_with_class() {
assert!(!should_override_type_typed(
&PhpType::parse("Session"),
&PhpType::parse("?int")
));
}
#[test]
fn override_nullable_object_with_class() {
assert!(should_override_type_typed(
&PhpType::parse("Session"),
&PhpType::parse("?object")
));
}
#[test]
fn no_override_scalar_union_with_class() {
assert!(!should_override_type_typed(
&PhpType::parse("Session"),
&PhpType::parse("string|int")
));
}
#[test]
fn override_union_with_object_part() {
assert!(should_override_type_typed(
&PhpType::parse("ConcreteClass"),
&PhpType::parse("SomeClass|null")
));
}
#[test]
fn no_override_when_docblock_is_scalar() {
assert!(!should_override_type_typed(
&PhpType::parse("int"),
&PhpType::parse("object")
));
}
#[test]
fn override_self_with_class() {
assert!(should_override_type_typed(
&PhpType::parse("ConcreteClass"),
&PhpType::parse("self")
));
}
#[test]
fn override_static_with_class() {
assert!(should_override_type_typed(
&PhpType::parse("ConcreteClass"),
&PhpType::parse("static")
));
}
#[test]
fn override_string_with_class_string() {
assert!(should_override_type_typed(
&PhpType::parse("class-string"),
&PhpType::parse("string")
));
}
#[test]
fn override_string_with_non_empty_string() {
assert!(should_override_type_typed(
&PhpType::parse("non-empty-string"),
&PhpType::parse("string")
));
}
#[test]
fn override_string_with_numeric_string() {
assert!(should_override_type_typed(
&PhpType::parse("numeric-string"),
&PhpType::parse("string")
));
}
#[test]
fn override_string_with_literal_string() {
assert!(should_override_type_typed(
&PhpType::parse("literal-string"),
&PhpType::parse("string")
));
}
#[test]
fn override_int_with_positive_int() {
assert!(should_override_type_typed(
&PhpType::parse("positive-int"),
&PhpType::parse("int")
));
}
#[test]
fn override_int_with_negative_int() {
assert!(should_override_type_typed(
&PhpType::parse("negative-int"),
&PhpType::parse("int")
));
}
#[test]
fn override_int_with_non_negative_int() {
assert!(should_override_type_typed(
&PhpType::parse("non-negative-int"),
&PhpType::parse("int")
));
}
#[test]
fn override_nullable_string_with_non_empty_string() {
assert!(should_override_type_typed(
&PhpType::parse("non-empty-string"),
&PhpType::parse("?string")
));
}
#[test]
fn override_string_with_class_string_generic() {
assert!(should_override_type_typed(
&PhpType::parse("class-string<Foo>"),
&PhpType::parse("string")
));
}
#[test]
fn no_override_string_with_array_generic() {
assert!(!should_override_type_typed(
&PhpType::parse("array<int>"),
&PhpType::parse("string")
));
}
#[test]
fn no_override_string_with_collection_generic() {
assert!(!should_override_type_typed(
&PhpType::parse("Collection<User>"),
&PhpType::parse("string")
));
}
#[test]
fn no_override_int_with_array_generic() {
assert!(!should_override_type_typed(
&PhpType::parse("array<int>"),
&PhpType::parse("int")
));
}
#[test]
fn no_override_int_with_class_name() {
assert!(!should_override_type_typed(
&PhpType::parse("Session"),
&PhpType::parse("int")
));
}
#[test]
fn no_override_bool_with_array_shape() {
assert!(!should_override_type_typed(
&PhpType::parse("array{name: string}"),
&PhpType::parse("bool")
));
}
#[test]
fn no_override_float_with_array_generic() {
assert!(!should_override_type_typed(
&PhpType::parse("array<string>"),
&PhpType::parse("float")
));
}
#[test]
fn override_int_with_int_range() {
assert!(should_override_type_typed(
&PhpType::parse("int<0, max>"),
&PhpType::parse("int")
));
}
#[test]
fn override_string_with_non_empty_string_generic() {
assert!(should_override_type_typed(
&PhpType::parse("non-empty-string"),
&PhpType::parse("string")
));
}
#[test]
fn no_override_string_with_list_generic() {
assert!(!should_override_type_typed(
&PhpType::parse("list<User>"),
&PhpType::parse("string")
));
}
#[test]
fn no_override_nullable_int_with_array_generic() {
assert!(!should_override_type_typed(
&PhpType::parse("array<int>"),
&PhpType::parse("?int")
));
}
#[test]
fn effective_type_docblock_only() {
assert_eq!(
resolve_effective_type_typed(None, Some(&PhpType::parse("Session"))).map(|t| t.to_string()),
Some("Session".into())
);
}
#[test]
fn effective_type_native_only() {
assert_eq!(
resolve_effective_type_typed(Some(&PhpType::parse("int")), None).map(|t| t.to_string()),
Some("int".into())
);
}
#[test]
fn effective_type_both_compatible() {
assert_eq!(
resolve_effective_type_typed(
Some(&PhpType::parse("object")),
Some(&PhpType::parse("Session"))
)
.map(|t| t.to_string()),
Some("Session".into())
);
}
#[test]
fn effective_type_both_incompatible() {
assert_eq!(
resolve_effective_type_typed(
Some(&PhpType::parse("int")),
Some(&PhpType::parse("Session"))
)
.map(|t| t.to_string()),
Some("int".into())
);
}
#[test]
fn effective_type_neither() {
assert_eq!(
resolve_effective_type_typed(None, None).map(|t| t.to_string()),
None::<String>
);
}
#[test]
fn conditional_simple_class_string() {
let doc = concat!(
"/**\n",
" * @return ($abstract is class-string<TClass> ? TClass : mixed)\n",
" */",
);
let result = extract_conditional_return_type(doc);
assert!(result.is_some(), "Should parse a conditional return type");
let cond = result.unwrap();
match cond {
PhpType::Conditional {
ref param,
negated,
ref condition,
ref then_type,
ref else_type,
} => {
assert_eq!(param, "$abstract");
assert!(!negated);
assert!(matches!(condition.as_ref(), PhpType::ClassString(_)));
assert_eq!(**then_type, PhpType::Named("TClass".into()));
assert_eq!(**else_type, PhpType::mixed());
}
_ => panic!("Expected Conditional, got {:?}", cond),
}
}
#[test]
fn conditional_null_check() {
let doc = concat!(
"/**\n",
" * @return ($guard is null ? \\Illuminate\\Contracts\\Auth\\Factory : \\Illuminate\\Contracts\\Auth\\StatefulGuard)\n",
" */",
);
let result = extract_conditional_return_type(doc).unwrap();
match result {
PhpType::Conditional {
param,
negated,
condition,
then_type,
else_type,
} => {
assert_eq!(param, "$guard");
assert!(!negated);
assert_eq!(*condition, PhpType::null());
assert_eq!(
*then_type,
PhpType::Named("\\Illuminate\\Contracts\\Auth\\Factory".into())
);
assert_eq!(
*else_type,
PhpType::Named("\\Illuminate\\Contracts\\Auth\\StatefulGuard".into())
);
}
_ => panic!("Expected Conditional"),
}
}
#[test]
fn conditional_nested() {
let doc = concat!(
"/**\n",
" * @return ($abstract is class-string<TClass> ? TClass : ($abstract is null ? \\Illuminate\\Foundation\\Application : mixed))\n",
" */",
);
let result = extract_conditional_return_type(doc).unwrap();
match result {
PhpType::Conditional {
ref param,
negated,
ref condition,
ref then_type,
ref else_type,
} => {
assert_eq!(param, "$abstract");
assert!(!negated);
assert!(matches!(condition.as_ref(), PhpType::ClassString(_)));
assert_eq!(**then_type, PhpType::Named("TClass".into()));
match else_type.as_ref() {
PhpType::Conditional {
param: inner_param,
negated: inner_negated,
condition: inner_cond,
then_type: inner_then,
else_type: inner_else,
} => {
assert_eq!(inner_param, "$abstract");
assert!(!inner_negated);
assert_eq!(**inner_cond, PhpType::null());
assert_eq!(
**inner_then,
PhpType::Named("\\Illuminate\\Foundation\\Application".into())
);
assert_eq!(**inner_else, PhpType::mixed());
}
_ => panic!("Expected nested Conditional"),
}
}
_ => panic!("Expected Conditional"),
}
}
#[test]
fn conditional_multiline() {
let doc = concat!(
"/**\n",
" * Get the available container instance.\n",
" *\n",
" * @param string|callable|null $abstract\n",
" * @return ($abstract is class-string<TClass>\n",
" * ? TClass\n",
" * : ($abstract is null\n",
" * ? \\Illuminate\\Foundation\\Application\n",
" * : mixed))\n",
" */",
);
let result = extract_conditional_return_type(doc);
assert!(result.is_some(), "Should parse multi-line conditional");
match result.unwrap() {
PhpType::Conditional {
param, condition, ..
} => {
assert_eq!(param, "$abstract");
assert!(matches!(condition.as_ref(), PhpType::ClassString(_)));
}
_ => panic!("Expected Conditional"),
}
}
#[test]
fn conditional_is_type() {
let doc = concat!(
"/**\n",
" * @return ($job is \\Closure ? \\Illuminate\\Foundation\\Bus\\PendingClosureDispatch : \\Illuminate\\Foundation\\Bus\\PendingDispatch)\n",
" */",
);
let result = extract_conditional_return_type(doc).unwrap();
match result {
PhpType::Conditional {
param,
negated,
condition,
then_type,
else_type,
} => {
assert_eq!(param, "$job");
assert!(!negated);
assert_eq!(*condition, PhpType::Named("\\Closure".into()));
assert_eq!(
*then_type,
PhpType::Named("\\Illuminate\\Foundation\\Bus\\PendingClosureDispatch".into())
);
assert_eq!(
*else_type,
PhpType::Named("\\Illuminate\\Foundation\\Bus\\PendingDispatch".into())
);
}
_ => panic!("Expected Conditional"),
}
}
#[test]
fn conditional_not_present() {
let doc = "/** @return Application */";
assert_eq!(extract_conditional_return_type(doc), None);
}
#[test]
fn conditional_no_return_tag() {
let doc = "/** Just a comment */";
assert_eq!(extract_conditional_return_type(doc), None);
}
#[test]
fn mixin_tag_simple() {
let doc = concat!("/**\n", " * @mixin ShoppingCart\n", " */",);
let mixins = extract_mixin_tags(doc);
assert_eq!(mixins, vec![("ShoppingCart".to_string(), vec![])]);
}
#[test]
fn mixin_tag_fqn() {
let doc = concat!("/**\n", " * @mixin \\App\\Models\\ShoppingCart\n", " */",);
let mixins = extract_mixin_tags(doc);
assert_eq!(
mixins,
vec![("\\App\\Models\\ShoppingCart".to_string(), vec![])]
);
}
#[test]
fn mixin_tag_multiple() {
let doc = concat!(
"/**\n",
" * @mixin ShoppingCart\n",
" * @mixin Wishlist\n",
" */",
);
let mixins = extract_mixin_tags(doc);
assert_eq!(
mixins,
vec![
("ShoppingCart".to_string(), vec![]),
("Wishlist".to_string(), vec![]),
]
);
}
#[test]
fn mixin_tag_none_when_missing() {
let doc = "/** Just a comment */";
let mixins = extract_mixin_tags(doc);
assert!(mixins.is_empty());
}
#[test]
fn mixin_tag_with_description() {
let doc = concat!(
"/**\n",
" * @mixin ShoppingCart Some extra description\n",
" */",
);
let mixins = extract_mixin_tags(doc);
assert_eq!(mixins, vec![("ShoppingCart".to_string(), vec![])]);
}
#[test]
fn mixin_tag_generic_preserved() {
let doc = concat!("/**\n", " * @mixin Collection<int, Model>\n", " */",);
let mixins = extract_mixin_tags(doc);
assert_eq!(
mixins,
vec![(
"Collection".to_string(),
vec![PhpType::parse("int"), PhpType::parse("Model")],
)]
);
}
#[test]
fn mixin_tag_mixed_with_other_tags() {
let doc = concat!(
"/**\n",
" * @property string $name\n",
" * @mixin ShoppingCart\n",
" * @method int getId()\n",
" */",
);
let mixins = extract_mixin_tags(doc);
assert_eq!(mixins, vec![("ShoppingCart".to_string(), vec![])]);
}
#[test]
fn mixin_tag_empty_after_tag() {
let doc = concat!("/**\n", " * @mixin\n", " */",);
let mixins = extract_mixin_tags(doc);
assert!(mixins.is_empty());
}
#[test]
fn assert_simple_phpstan() {
let doc = concat!("/**\n", " * @phpstan-assert User $value\n", " */",);
let assertions = extract_type_assertions(doc);
assert_eq!(assertions.len(), 1);
assert_eq!(assertions[0].kind, AssertionKind::Always);
assert_eq!(assertions[0].param_name, "$value");
assert_eq!(assertions[0].asserted_type.to_string(), "User");
assert!(!assertions[0].negated);
}
#[test]
fn assert_simple_psalm() {
let doc = concat!("/**\n", " * @psalm-assert AdminUser $obj\n", " */",);
let assertions = extract_type_assertions(doc);
assert_eq!(assertions.len(), 1);
assert_eq!(assertions[0].kind, AssertionKind::Always);
assert_eq!(assertions[0].param_name, "$obj");
assert_eq!(assertions[0].asserted_type.to_string(), "AdminUser");
assert!(!assertions[0].negated);
}
#[test]
fn assert_negated() {
let doc = concat!("/**\n", " * @phpstan-assert !User $value\n", " */",);
let assertions = extract_type_assertions(doc);
assert_eq!(assertions.len(), 1);
assert_eq!(assertions[0].kind, AssertionKind::Always);
assert_eq!(assertions[0].asserted_type.to_string(), "User");
assert!(assertions[0].negated);
}
#[test]
fn assert_if_true() {
let doc = concat!("/**\n", " * @phpstan-assert-if-true User $value\n", " */",);
let assertions = extract_type_assertions(doc);
assert_eq!(assertions.len(), 1);
assert_eq!(assertions[0].kind, AssertionKind::IfTrue);
assert_eq!(assertions[0].param_name, "$value");
assert_eq!(assertions[0].asserted_type.to_string(), "User");
assert!(!assertions[0].negated);
}
#[test]
fn assert_if_false() {
let doc = concat!("/**\n", " * @phpstan-assert-if-false User $value\n", " */",);
let assertions = extract_type_assertions(doc);
assert_eq!(assertions.len(), 1);
assert_eq!(assertions[0].kind, AssertionKind::IfFalse);
assert_eq!(assertions[0].param_name, "$value");
assert_eq!(assertions[0].asserted_type.to_string(), "User");
assert!(!assertions[0].negated);
}
#[test]
fn assert_psalm_if_true() {
let doc = concat!("/**\n", " * @psalm-assert-if-true AdminUser $obj\n", " */",);
let assertions = extract_type_assertions(doc);
assert_eq!(assertions.len(), 1);
assert_eq!(assertions[0].kind, AssertionKind::IfTrue);
assert_eq!(assertions[0].param_name, "$obj");
assert_eq!(assertions[0].asserted_type.to_string(), "AdminUser");
}
#[test]
fn assert_fqn_type() {
let doc = concat!(
"/**\n",
" * @phpstan-assert \\App\\Models\\User $value\n",
" */",
);
let assertions = extract_type_assertions(doc);
assert_eq!(assertions.len(), 1);
assert_eq!(
assertions[0].asserted_type.to_string(),
"\\App\\Models\\User"
);
}
#[test]
fn assert_multiple_annotations() {
let doc = concat!(
"/**\n",
" * @phpstan-assert User $first\n",
" * @phpstan-assert AdminUser $second\n",
" */",
);
let assertions = extract_type_assertions(doc);
assert_eq!(assertions.len(), 2);
assert_eq!(assertions[0].param_name, "$first");
assert_eq!(assertions[0].asserted_type.to_string(), "User");
assert_eq!(assertions[1].param_name, "$second");
assert_eq!(assertions[1].asserted_type.to_string(), "AdminUser");
}
#[test]
fn assert_mixed_with_other_tags() {
let doc = concat!(
"/**\n",
" * Some description.\n",
" *\n",
" * @param mixed $value\n",
" * @phpstan-assert User $value\n",
" * @return void\n",
" */",
);
let assertions = extract_type_assertions(doc);
assert_eq!(assertions.len(), 1);
assert_eq!(assertions[0].asserted_type.to_string(), "User");
}
#[test]
fn assert_none_when_missing() {
let doc = "/** @return void */";
let assertions = extract_type_assertions(doc);
assert!(assertions.is_empty());
}
#[test]
fn assert_empty_after_tag_ignored() {
let doc = concat!("/**\n", " * @phpstan-assert\n", " */",);
let assertions = extract_type_assertions(doc);
assert!(assertions.is_empty());
}
#[test]
fn assert_missing_param_ignored() {
let doc = concat!("/**\n", " * @phpstan-assert User\n", " */",);
let assertions = extract_type_assertions(doc);
assert!(assertions.is_empty());
}
#[test]
fn assert_param_without_dollar_ignored() {
let doc = concat!("/**\n", " * @phpstan-assert User value\n", " */",);
let assertions = extract_type_assertions(doc);
assert!(assertions.is_empty());
}
#[test]
fn assert_negated_if_true() {
let doc = concat!("/**\n", " * @phpstan-assert-if-true !User $value\n", " */",);
let assertions = extract_type_assertions(doc);
assert_eq!(assertions.len(), 1);
assert_eq!(assertions[0].kind, AssertionKind::IfTrue);
assert!(assertions[0].negated);
assert_eq!(assertions[0].asserted_type.to_string(), "User");
}
#[test]
fn deprecated_tag_bare() {
let doc = concat!("/**\n", " * @deprecated\n", " */",);
assert!(has_deprecated_tag(doc));
}
#[test]
fn deprecated_tag_with_message() {
let doc = concat!("/**\n", " * @deprecated Use newMethod() instead.\n", " */",);
assert!(has_deprecated_tag(doc));
}
#[test]
fn deprecated_tag_with_version() {
let doc = concat!("/**\n", " * @deprecated since 2.0\n", " */",);
assert!(has_deprecated_tag(doc));
}
#[test]
fn deprecated_tag_mixed_with_other_tags() {
let doc = concat!(
"/**\n",
" * Some description.\n",
" *\n",
" * @param string $name\n",
" * @deprecated Use something else.\n",
" * @return void\n",
" */",
);
assert!(has_deprecated_tag(doc));
}
#[test]
fn deprecated_tag_not_present() {
let doc = concat!(
"/**\n",
" * @param string $name\n",
" * @return void\n",
" */",
);
assert!(!has_deprecated_tag(doc));
}
#[test]
fn deprecated_tag_empty_docblock() {
let doc = "/** */";
assert!(!has_deprecated_tag(doc));
}
#[test]
fn deprecated_tag_not_confused_with_similar_words() {
let doc = concat!("/**\n", " * @deprecatedAlias\n", " */",);
assert!(!has_deprecated_tag(doc));
}
#[test]
fn deprecated_tag_at_end_of_line() {
let doc = "/** @deprecated */";
assert!(has_deprecated_tag(doc));
}
#[test]
fn deprecated_tag_with_tab_separator() {
let doc = concat!("/**\n", " * @deprecated\tUse foo() instead\n", " */",);
assert!(has_deprecated_tag(doc));
}
#[test]
fn enclosing_return_type_method() {
let content = concat!(
"<?php\n",
"class Foo {\n",
" /** @return \\Generator<int, User> */\n",
" public function bar(): \\Generator {\n",
" yield $x;\n",
" $x->\n",
" }\n",
"}\n",
);
let cursor = content.find("$x->").unwrap() + 2;
assert_eq!(
find_enclosing_return_type(content, cursor),
Some(PhpType::parse("\\Generator<int, User>"))
);
}
#[test]
fn enclosing_return_type_top_level_function() {
let content = concat!(
"<?php\n",
"/** @return \\Generator<int, Order> */\n",
"function gen(): \\Generator {\n",
" yield $o;\n",
" $o->\n",
"}\n",
);
let cursor = content.find("$o->").unwrap() + 2;
assert_eq!(
find_enclosing_return_type(content, cursor),
Some(PhpType::parse("\\Generator<int, Order>"))
);
}
#[test]
fn enclosing_return_type_no_docblock() {
let content = concat!(
"<?php\n",
"function gen(): \\Generator {\n",
" yield $x;\n",
" $x->\n",
"}\n",
);
let cursor = content.find("$x->").unwrap() + 2;
assert_eq!(find_enclosing_return_type(content, cursor), None);
}
#[test]
fn enclosing_return_type_static_method() {
let content = concat!(
"<?php\n",
"class Svc {\n",
" /** @return \\Generator<int, User> */\n",
" public static function run(): \\Generator {\n",
" yield $u;\n",
" $u->\n",
" }\n",
"}\n",
);
let cursor = content.find("$u->").unwrap() + 2;
assert_eq!(
find_enclosing_return_type(content, cursor),
Some(PhpType::parse("\\Generator<int, User>"))
);
}
#[test]
fn enclosing_return_type_abstract_protected() {
let content = concat!(
"<?php\n",
"class Base {\n",
" /** @return \\Generator<string, Item> */\n",
" protected function items(): \\Generator {\n",
" yield $i;\n",
" $i->\n",
" }\n",
"}\n",
);
let cursor = content.find("$i->").unwrap() + 2;
assert_eq!(
find_enclosing_return_type(content, cursor),
Some(PhpType::parse("\\Generator<string, Item>"))
);
}
#[test]
fn enclosing_return_type_skips_nested_braces() {
let content = concat!(
"<?php\n",
"class Repo {\n",
" /** @return \\Generator<int, User> */\n",
" public function find(): \\Generator {\n",
" if (true) {\n",
" $x = 1;\n",
" }\n",
" yield $u;\n",
" $u->\n",
" }\n",
"}\n",
);
let cursor = content.find("$u->").unwrap() + 2;
assert_eq!(
find_enclosing_return_type(content, cursor),
Some(PhpType::parse("\\Generator<int, User>"))
);
}
#[test]
fn enclosing_return_type_deeply_nested_control_flow() {
let content = concat!(
"<?php\n",
"class Scheduler {\n",
" /** @return \\Generator<int, string, Task, void> */\n",
" public function schedule(): \\Generator {\n",
" while (true) {\n",
" if (true) {\n",
" $task = yield 'waiting';\n",
" $task->\n",
" }\n",
" }\n",
" }\n",
"}\n",
);
let func_brace =
content.find("schedule(): \\Generator {").unwrap() + "schedule(): \\Generator {".len();
assert_eq!(
find_enclosing_return_type(content, func_brace),
Some(PhpType::parse("\\Generator<int, string, Task, void>")),
"Should find return type when scanning from just past the method's opening brace"
);
}
#[test]
fn template_default_simple_bool() {
let doc = concat!("/**\n", " * @template TAsync of bool = false\n", " */",);
let result = extract_template_params_full(doc);
assert_eq!(result.len(), 1);
let (name, bound, _, default) = &result[0];
assert_eq!(name, "TAsync");
assert_eq!(*bound, Some(PhpType::parse("bool")));
assert_eq!(*default, Some(PhpType::parse("false")));
}
#[test]
fn template_default_true() {
let doc = concat!("/**\n", " * @template TSync of bool = true\n", " */",);
let result = extract_template_params_full(doc);
assert_eq!(result.len(), 1);
let (name, bound, _, default) = &result[0];
assert_eq!(name, "TSync");
assert_eq!(*bound, Some(PhpType::parse("bool")));
assert_eq!(*default, Some(PhpType::parse("true")));
}
#[test]
fn template_default_null() {
let doc = concat!("/**\n", " * @template TValue of mixed = null\n", " */",);
let result = extract_template_params_full(doc);
assert_eq!(result.len(), 1);
let (name, bound, _, default) = &result[0];
assert_eq!(name, "TValue");
assert_eq!(*bound, Some(PhpType::parse("mixed")));
assert_eq!(*default, Some(PhpType::parse("null")));
}
#[test]
fn template_no_default() {
let doc = concat!("/**\n", " * @template T of string\n", " */",);
let result = extract_template_params_full(doc);
assert_eq!(result.len(), 1);
let (name, bound, _, default) = &result[0];
assert_eq!(name, "T");
assert_eq!(*bound, Some(PhpType::parse("string")));
assert!(default.is_none());
}
#[test]
fn template_no_bound_no_default() {
let doc = concat!("/**\n", " * @template T\n", " */",);
let result = extract_template_params_full(doc);
assert_eq!(result.len(), 1);
let (name, bound, _, default) = &result[0];
assert_eq!(name, "T");
assert!(bound.is_none());
assert!(default.is_none());
}
#[test]
fn template_multiple_with_defaults() {
let doc = concat!(
"/**\n",
" * @template TKey of int\n",
" * @template TAsync of bool = false\n",
" * @template TValue of string = null\n",
" */",
);
let result = extract_template_params_full(doc);
assert_eq!(result.len(), 3);
let (name0, bound0, _, default0) = &result[0];
assert_eq!(name0, "TKey");
assert_eq!(*bound0, Some(PhpType::parse("int")));
assert!(default0.is_none());
let (name1, bound1, _, default1) = &result[1];
assert_eq!(name1, "TAsync");
assert_eq!(*bound1, Some(PhpType::parse("bool")));
assert_eq!(*default1, Some(PhpType::parse("false")));
let (name2, bound2, _, default2) = &result[2];
assert_eq!(name2, "TValue");
assert_eq!(*bound2, Some(PhpType::parse("string")));
assert_eq!(*default2, Some(PhpType::parse("null")));
}
#[test]
fn template_default_stripped_from_bound() {
let doc = concat!("/**\n", " * @template TAsync of bool = false\n", " */",);
let params_with_bounds = extract_template_params_with_bounds(doc);
assert_eq!(params_with_bounds.len(), 1);
let (name, bound) = ¶ms_with_bounds[0];
assert_eq!(name, "TAsync");
assert_eq!(*bound, Some(PhpType::parse("bool")));
}
#[test]
fn template_default_stripped_from_names() {
let doc = concat!("/**\n", " * @template TAsync of bool = false\n", " */",);
let params = extract_template_params(doc);
assert_eq!(params, vec!["TAsync"]);
}
#[test]
fn conditional_resolves_with_template_default_false() {
use phpantom_lsp::completion::conditional_resolution::resolve_conditional_without_args_and_defaults;
use std::collections::HashMap;
let cond = PhpType::Conditional {
param: "TAsync".to_string(),
negated: false,
condition: Box::new(PhpType::false_()),
then_type: Box::new(PhpType::Named("Response".to_string())),
else_type: Box::new(PhpType::Named("PromiseInterface".to_string())),
};
let mut defaults = HashMap::new();
defaults.insert("TAsync".to_string(), PhpType::false_());
let result = resolve_conditional_without_args_and_defaults(&cond, &[], Some(&defaults));
assert_eq!(result, Some(PhpType::Named("Response".to_string())));
}
#[test]
fn conditional_resolves_with_template_default_true() {
use phpantom_lsp::completion::conditional_resolution::resolve_conditional_without_args_and_defaults;
use std::collections::HashMap;
let cond = PhpType::Conditional {
param: "TAsync".to_string(),
negated: false,
condition: Box::new(PhpType::false_()),
then_type: Box::new(PhpType::Named("Response".to_string())),
else_type: Box::new(PhpType::Named("PromiseInterface".to_string())),
};
let mut defaults = HashMap::new();
defaults.insert("TAsync".to_string(), PhpType::true_());
let result = resolve_conditional_without_args_and_defaults(&cond, &[], Some(&defaults));
assert_eq!(result, Some(PhpType::Named("PromiseInterface".to_string())));
}
#[test]
fn conditional_no_template_default_falls_through() {
use phpantom_lsp::completion::conditional_resolution::resolve_conditional_without_args_and_defaults;
use std::collections::HashMap;
let cond = PhpType::Conditional {
param: "TAsync".to_string(),
negated: false,
condition: Box::new(PhpType::false_()),
then_type: Box::new(PhpType::Named("Response".to_string())),
else_type: Box::new(PhpType::Named("PromiseInterface".to_string())),
};
let defaults = HashMap::new();
let result = resolve_conditional_without_args_and_defaults(&cond, &[], Some(&defaults));
assert_eq!(result, Some(PhpType::Named("PromiseInterface".to_string())));
}
#[test]
fn conditional_negated_with_template_default() {
use phpantom_lsp::completion::conditional_resolution::resolve_conditional_without_args_and_defaults;
use std::collections::HashMap;
let cond = PhpType::Conditional {
param: "TAsync".to_string(),
negated: true,
condition: Box::new(PhpType::false_()),
then_type: Box::new(PhpType::Named("PromiseInterface".to_string())),
else_type: Box::new(PhpType::Named("Response".to_string())),
};
let mut defaults = HashMap::new();
defaults.insert("TAsync".to_string(), PhpType::false_());
let result = resolve_conditional_without_args_and_defaults(&cond, &[], Some(&defaults));
assert_eq!(result, Some(PhpType::Named("Response".to_string())));
}