Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Stack overflow when a foreach value variable shadows the iterator receiver.** Patterns like `foreach ($category->getBranch() as $category)` caused infinite recursion during type resolution because resolving the value variable re-entered the same foreach. The foreach resolver now detects this cycle at the AST level and skips the recursive path. A depth guard on `resolve_variable_types` provides a safety net for any remaining recursive patterns.
- **PHPStan diagnostics hidden when a native diagnostic exists on the same line.** Full-line PHPStan diagnostics were suppressed whenever any precise native diagnostic appeared on the same line, even for completely unrelated issues. For example, `class.prefixed` was hidden because a native `unknown_class` diagnostic covered the same line. Deduplication now only suppresses a full-line diagnostic when the precise diagnostic on that line reports a related issue.
- **Deprecated class in `implements` now renders with strikethrough.** Verified and tested that `DiagnosticTag::DEPRECATED` applies correctly for deprecated classes referenced in `implements` clauses, matching the existing coverage for `new`, type hints, and `extends`. Also verified that `$this`/`self`/`static` resolve to the correct class in files with multiple class declarations.
- **`@param` docblock overrides ignored when the native type hint resolves.** When a method parameter had both a native type hint (e.g. `Node $node`) and a `@param` override with a more specific type (e.g. `@param FuncCall $node`), completions and diagnostics used the native type because it resolved to a class first. The docblock override is now checked before resolution so the more specific type takes effect. Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/55.

## [0.6.0] - 2026-03-26

Expand Down
73 changes: 62 additions & 11 deletions src/completion/types/conditional.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ pub fn resolve_conditional_with_text_args_and_defaults(
for arg in args.iter().skip(param_idx) {
let trimmed = arg.trim();
if let Some(class_name) = extract_class_name_from_text(trimmed) {
let class_name = resolve_self_keyword(&class_name, calling_class_name).unwrap_or(class_name);
let class_name =
resolve_self_keyword(&class_name, calling_class_name)
.unwrap_or(class_name);
if !class_names.contains(&class_name) {
class_names.push(class_name);
}
Expand Down Expand Up @@ -182,7 +184,8 @@ pub fn resolve_conditional_with_text_args_and_defaults(
if let Some(arg) = arg_text
&& let Some(class_name) = extract_class_name_from_text(arg)
{
let class_name = resolve_self_keyword(&class_name, calling_class_name).unwrap_or(class_name);
let class_name = resolve_self_keyword(&class_name, calling_class_name)
.unwrap_or(class_name);
return Some(class_name);
}
// Check if the argument is a variable holding class-string
Expand All @@ -198,7 +201,14 @@ pub fn resolve_conditional_with_text_args_and_defaults(
}
}
// Argument isn't a ::class literal or resolvable variable → try else branch
resolve_conditional_with_text_args_and_defaults(else_type, params, text_args, var_resolver, calling_class_name, template_defaults)
resolve_conditional_with_text_args_and_defaults(
else_type,
params,
text_args,
var_resolver,
calling_class_name,
template_defaults,
)
}
PhpType::Named(s) if s == "null" => {
if arg_text.is_none() || arg_text == Some("") || arg_text == Some("null") {
Expand Down Expand Up @@ -250,7 +260,14 @@ pub fn resolve_conditional_with_text_args_and_defaults(
}
}
// Argument doesn't match the literal → else branch.
resolve_conditional_with_text_args_and_defaults(else_type, params, text_args, var_resolver, calling_class_name, template_defaults)
resolve_conditional_with_text_args_and_defaults(
else_type,
params,
text_args,
var_resolver,
calling_class_name,
template_defaults,
)
}
_ => {
// IsType equivalent: can't statically determine most
Expand All @@ -271,7 +288,14 @@ pub fn resolve_conditional_with_text_args_and_defaults(
);
}
// Can't statically determine; fall through to else.
resolve_conditional_with_text_args_and_defaults(else_type, params, text_args, var_resolver, calling_class_name, template_defaults)
resolve_conditional_with_text_args_and_defaults(
else_type,
params,
text_args,
var_resolver,
calling_class_name,
template_defaults,
)
}
}
}
Expand Down Expand Up @@ -447,7 +471,8 @@ pub fn resolve_conditional_with_args_and_defaults<'b>(
PhpType::ClassString(_) => {
// Check if the argument is `X::class`
if let Some(class_name) = arg_expr.and_then(extract_class_string_from_expr) {
let class_name = resolve_self_keyword(&class_name, calling_class_name).unwrap_or(class_name);
let class_name = resolve_self_keyword(&class_name, calling_class_name)
.unwrap_or(class_name);
return Some(class_name);
}
// Check if the argument is a variable holding class-string
Expand All @@ -461,7 +486,14 @@ pub fn resolve_conditional_with_args_and_defaults<'b>(
}
}
// Argument isn't a ::class literal or resolvable variable → try else branch
resolve_conditional_with_args_and_defaults(else_type, params, argument_list, var_resolver, calling_class_name, template_defaults)
resolve_conditional_with_args_and_defaults(
else_type,
params,
argument_list,
var_resolver,
calling_class_name,
template_defaults,
)
}
PhpType::Named(s) if s == "null" => {
if arg_expr.is_none() {
Expand Down Expand Up @@ -543,7 +575,14 @@ pub fn resolve_conditional_with_args_and_defaults<'b>(
}
// We can't statically determine the type of an
// arbitrary expression; fall through to else.
resolve_conditional_with_args_and_defaults(else_type, params, argument_list, var_resolver, calling_class_name, template_defaults)
resolve_conditional_with_args_and_defaults(
else_type,
params,
argument_list,
var_resolver,
calling_class_name,
template_defaults,
)
}
}
}
Expand Down Expand Up @@ -614,11 +653,19 @@ pub fn resolve_conditional_without_args_and_defaults(

match condition.as_ref() {
PhpType::Named(s) if s == "null" && has_null_default => {
resolve_conditional_without_args_and_defaults(then_type, params, template_defaults)
resolve_conditional_without_args_and_defaults(
then_type,
params,
template_defaults,
)
}
_ => {
// Try else branch
resolve_conditional_without_args_and_defaults(else_type, params, template_defaults)
resolve_conditional_without_args_and_defaults(
else_type,
params,
template_defaults,
)
}
}
}
Expand Down Expand Up @@ -690,7 +737,11 @@ fn try_resolve_with_template_default(
condition_matches
};

let branch = if effective_match { then_type } else { else_type };
let branch = if effective_match {
then_type
} else {
else_type
};
let ty = branch.to_string();
if ty == "mixed" || ty == "void" || ty == "never" {
return None;
Expand Down
52 changes: 32 additions & 20 deletions src/completion/variable/resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -756,35 +756,50 @@ fn resolve_variable_in_members<'b>(
let type_str_for_resolution =
enriched_type_str.as_deref().or(native_type_str.as_deref());

let resolved_from_native = type_str_for_resolution
.map(|ts| {
crate::completion::type_resolution::type_hint_to_classes(
ts,
// Check the `@param` docblock annotation which may
// carry a more specific type than the native hint
// (e.g. `@param FuncCall $node` on `Node $node`).
let method_start = method.span().start.offset as usize;
let raw_docblock_type = crate::docblock::find_iterable_raw_type_in_source(
ctx.content,
method_start,
ctx.var_name,
);

// Pick the effective type: docblock overrides native
// when it is a compatible refinement.
let effective_type = crate::docblock::resolve_effective_type(
type_str_for_resolution,
raw_docblock_type.as_deref(),
);

let resolved_from_effective = effective_type
.as_ref()
.map(|ty| {
crate::completion::type_resolution::type_hint_to_classes_typed(
ty,
&ctx.current_class.name,
ctx.all_classes,
ctx.class_loader,
)
})
.unwrap_or_default();

if !resolved_from_native.is_empty() {
if !resolved_from_effective.is_empty() {
param_results = ResolvedType::from_classes_with_hint(
resolved_from_native,
PhpType::parse(type_str_for_resolution.unwrap_or("")),
resolved_from_effective,
effective_type.unwrap_or_else(|| {
PhpType::parse(type_str_for_resolution.unwrap_or(""))
}),
);
break;
}

// Native hint didn't resolve (e.g. `object`, `mixed`).
// Fall back to the `@param` docblock annotation which
// may carry a more specific type such as
// The effective type didn't resolve to a class (e.g.
// `object`, `mixed`, or an object shape). Fall back to
// the raw `@param` docblock annotation which may carry
// a more specific non-class type such as
// `object{foo: int, bar: string}`.
let method_start = method.span().start.offset as usize;
let raw_docblock_type = crate::docblock::find_iterable_raw_type_in_source(
ctx.content,
method_start,
ctx.var_name,
);
if let Some(ref raw_docblock_type) = raw_docblock_type {
let resolved = crate::completion::type_resolution::type_hint_to_classes(
raw_docblock_type,
Expand Down Expand Up @@ -894,10 +909,7 @@ fn resolve_variable_in_members<'b>(
// Fall back to a standalone `@var` docblock scan
// when parameter resolution and assignment walking
// yielded no type.
super::closure_resolution::try_standalone_var_docblock(
&body_ctx,
&mut results,
);
super::closure_resolution::try_standalone_var_docblock(&body_ctx, &mut results);
if !results.is_empty() {
return results;
}
Expand Down
5 changes: 1 addition & 4 deletions src/completion/variable/rhs_resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -816,10 +816,7 @@ fn resolve_rhs_array_access<'b>(
)
.into_iter()
.find_map(|cls| {
let merged = crate::virtual_members::resolve_class_fully(
&cls,
ctx.class_loader,
);
let merged = crate::virtual_members::resolve_class_fully(&cls, ctx.class_loader);
super::foreach_resolution::extract_iterable_element_type_from_class(
&merged,
ctx.class_loader,
Expand Down
4 changes: 3 additions & 1 deletion src/diagnostics/unknown_members.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4052,7 +4052,9 @@ class Foo {
let backend = Backend::new_test();
let diags = collect(&backend, "file:///test.php", php);
assert!(
diags.iter().any(|d| d.message.contains("nonExistentMethod")),
diags
.iter()
.any(|d| d.message.contains("nonExistentMethod")),
"expected unknown member diagnostic for nonExistentMethod, got: {diags:?}",
);
}
Expand Down
17 changes: 10 additions & 7 deletions src/hover/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1365,10 +1365,15 @@ impl Backend {
let bound_display = bound
.map(|b| format!(" of `{}`", PhpType::parse(&b).shorten()))
.unwrap_or_default();
let default_display = default
.map(|d| format!(" = `{}`", d))
.unwrap_or_default();
format!("**{}** `{}`{}{}", variance.tag_name(), name, bound_display, default_display)
let default_display =
default.map(|d| format!(" = `{}`", d)).unwrap_or_default();
format!(
"**{}** `{}`{}{}",
variance.tag_name(),
name,
bound_display,
default_display
)
})
.collect();
if !tpl_entries.is_empty() {
Expand Down Expand Up @@ -1562,9 +1567,7 @@ fn find_template_info_in_class(type_str: &str, owner: &ClassInfo) -> Option<Stri
let bound_display = bound
.map(|b| format!(" of `{}`", PhpType::parse(&b).shorten()))
.unwrap_or_default();
let default_display = default
.map(|d| format!(" = `{}`", d))
.unwrap_or_default();
let default_display = default.map(|d| format!(" = `{}`", d)).unwrap_or_default();

Some(format!(
"**{}** `{}`{}{}",
Expand Down
12 changes: 5 additions & 7 deletions src/virtual_members/laravel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,9 @@ pub(crate) fn try_inject_mixin_builder_scopes(
// Also check the class itself (it might directly declare @mixin Builder<X>).
loop {
// Check for Builder mixin on the current class.
if let Some(model_name) = find_builder_mixin_model(&current, &active_subs, raw_cls, class_loader) {
if let Some(model_name) =
find_builder_mixin_model(&current, &active_subs, raw_cls, class_loader)
{
inject_scopes_and_model_methods(result, &model_name, class_loader);
return;
}
Expand Down Expand Up @@ -315,9 +317,7 @@ fn find_builder_mixin_model(
use crate::util::short_name;

for mixin_name in &class.mixins {
if short_name(mixin_name) != "Builder"
&& mixin_name != ELOQUENT_BUILDER_FQN
{
if short_name(mixin_name) != "Builder" && mixin_name != ELOQUENT_BUILDER_FQN {
continue;
}
// Verify it's actually the Eloquent Builder (not some other
Expand Down Expand Up @@ -348,9 +348,7 @@ fn find_builder_mixin_model(
let model_name = resolved.to_string();
// Only inject if we resolved to a concrete type
// (not still a template parameter name).
if !model_name.is_empty()
&& !root_cls.template_params.contains(&model_name)
{
if !model_name.is_empty() && !root_cls.template_params.contains(&model_name) {
return Some(model_name);
}
}
Expand Down
72 changes: 72 additions & 0 deletions tests/integration/completion_variables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,78 @@ async fn test_completion_static_double_colon() {
}
}

#[tokio::test]
async fn test_completion_parameter_uses_param_docblock_override() {
let backend = create_test_backend();

let uri = Url::parse("file:///param_docblock_override.php").unwrap();
let text = r#"<?php
class Node {}
class FuncCall extends Node {
public function isFirstClassCallable(): bool {}
public function getName(): string {}
}
class Handler {
/**
* @param FuncCall $node
*/
public function handle(Node $node): void {
$node->
}
}
"#;

let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
};
backend.did_open(open_params).await;

let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 11,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};

let result = backend.completion(completion_params).await.unwrap();
assert!(
result.is_some(),
"Completion should resolve $node via @param override"
);

match result.unwrap() {
CompletionResponse::Array(items) => {
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.filter_map(|i| i.filter_text.as_deref())
.collect();
assert!(
method_names.contains(&"isFirstClassCallable"),
"Should include 'isFirstClassCallable', got: {:?}",
method_names
);
assert!(
method_names.contains(&"getName"),
"Should include 'getName', got: {:?}",
method_names
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}

// ─── Completion: new ClassName()-> and (new ClassName())-> ─────────────────

#[tokio::test]
Expand Down
Loading
Loading