Skip to content

Commit 3b4f193

Browse files
committed
Fix 8 bugs
1 parent e10d989 commit 3b4f193

15 files changed

Lines changed: 3051 additions & 27 deletions

docs/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6868
- **First-open performance.** Diagnostics on `did_open` now run asynchronously instead of blocking the LSP response.
6969
- **Variadic `@param` template bindings.** `@param class-string<T> ...$items` now correctly binds the template parameter.
7070
- **Laravel relationship classification.** Relationship return types fully-qualified to a non-Eloquent namespace are no longer misclassified as Eloquent relationships.
71+
- **Trait `use` no longer triggers false-positive unused import.** When a class uses a trait via `use TraitName;` inside the class body, the corresponding namespace import is no longer flagged as unused.
72+
- **PHPDoc types on constructor-promoted properties now recognised.** Classes referenced in `/** @var list<Foo> */` annotations on promoted constructor parameters are no longer flagged as unused imports, and hover/go-to-definition works on those type references.
73+
- **PHPDoc type tags no longer skipped by unused-import safety net.** Docblock lines containing type-bearing tags (`@var`, `@param`, `@return`, `@throws`, `@template`, etc.) are now checked for class references instead of being blanket-skipped as comments.
7174
- **`@phpstan-type` aliases in foreach.** Type aliases now resolve correctly when iterated in a `foreach` loop, destructured with `list()`/`[]`, or used as a foreach key type.
7275
- **Mixed `->` then `::` accessor chains.** Expressions like `$obj->prop::$staticProp` now resolve through the full chain.
7376
- **Inline `(new Foo)->method()` chaining.** Parenthesized `new` expressions used as the root of a method chain now resolve for completion.
@@ -86,6 +89,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8689
- **Double-negated `instanceof` narrowing.** `if (!!$x instanceof Foo)` now correctly narrows `$x` to `Foo`.
8790
- **Accessor on new line with whitespace.** Completion now works when `->` is on a new line with extra whitespace before the cursor.
8891
- **Partial static property completion.** Typing `$obj::$f` now returns static property completions.
92+
- **Hover respects `instanceof` and `assert` narrowing.** Hovering a variable after `assert($var instanceof Foo)` now shows the narrowed type instead of the original assignment type. Inline `/** @var Type */` annotations on assignments are also respected by the hover type-string path. Previously these narrowing patterns only affected completion, not hover.
93+
- **`instanceof` narrowing with same-named classes in different namespaces.** When two classes share the same short name (e.g. `Contracts\Provider` and `Concrete\Provider`), `instanceof` narrowing no longer incorrectly treats them as subtypes of each other.
94+
- **Self-referential array key assignments no longer crash the LSP.** Patterns like `$numbers['price'] = $numbers['price']->add(...)` caused infinite recursion in the raw-type inference path, producing a stack overflow that killed the server process. The resolver now reduces the cursor offset before evaluating the right-hand side, matching the protection already present for simple variable assignments.
95+
- **Cross-file `@property` and `@method` type resolution.** When a class declares `@property Carbon $created` using a short class name imported via `use`, accessing that property from a different file now resolves the type correctly. Previously the short name was resolved against the consuming file's imports instead of the declaring file's imports, causing completion and hover to fail.
96+
- **Editing a `@property` docblock now invalidates hover in other files.** Changing a class-level `@property` (or `@method`) type was not reflected when hovering on a child class that inherits the virtual member. The resolved-class cache now transitively evicts dependent classes (children, trait users, implementors, mixin consumers) when an ancestor's signature changes.
8997

9098
## [0.4.0] - 2026-03-01
9199

example.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4556,7 +4556,7 @@ public function first($columns = ['*']) { return null; }
45564556
* @param callable(\Illuminate\Support\Collection<int, TValue>, int): mixed $callback
45574557
* @return bool
45584558
*/
4559-
public function chunk(int $count, callable $callback): bool { return true; }
4559+
public function chunk($count, callable $callback) { return true; }
45604560
}
45614561
}
45624562

@@ -4590,8 +4590,9 @@ public function get($columns = ['*']) {}
45904590
/**
45914591
* @template TKey of array-key
45924592
* @template TValue
4593+
* @implements \IteratorAggregate<TKey, TValue>
45934594
*/
4594-
class Collection {
4595+
class Collection implements \IteratorAggregate {
45954596
/** @return int */
45964597
public function count(): int { return 0; }
45974598
/** @return TValue|null */
@@ -4603,6 +4604,7 @@ public function all(): array { return []; }
46034604
* @return static
46044605
*/
46054606
public function each(callable $callback): static { return $this; }
4607+
public function getIterator(): \ArrayIterator { return new \ArrayIterator([]); }
46064608
}
46074609
}
46084610

src/completion/types/narrowing.rs

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ pub(in crate::completion) fn apply_instanceof_inclusion(
226226
.filter(|r| {
227227
narrowed
228228
.iter()
229-
.any(|n| is_subtype_of(r, &n.name, ctx.class_loader))
229+
.any(|n| is_subtype_of(r, &n.fqn(), ctx.class_loader))
230230
})
231231
.cloned()
232232
.collect();
@@ -267,18 +267,37 @@ fn is_subtype_of(
267267
) -> bool {
268268
let ancestor_short = ancestor_name.rsplit('\\').next().unwrap_or(ancestor_name);
269269

270-
// Same class?
271-
if class.name == ancestor_name || class.name == ancestor_short {
270+
// Same class? When the ancestor is a FQN, compare against the
271+
// class's own FQN to avoid false positives when two classes share
272+
// the same short name (e.g. `Contracts\Provider` vs
273+
// `Concrete\Provider`).
274+
if ancestor_name.contains('\\') {
275+
if class.fqn() == ancestor_name {
276+
return true;
277+
}
278+
} else if class.name == ancestor_name {
272279
return true;
273280
}
274281

282+
// When the ancestor is qualified, only match against normalised
283+
// FQNs — never fall back to short-name comparison, which would
284+
// produce false positives when two different classes share the
285+
// same short name (e.g. `Contracts\Provider` vs
286+
// `Concrete\Provider`).
287+
let fqn_mode = ancestor_name.contains('\\');
288+
275289
// Check interfaces on the class itself.
276290
for iface in &class.interfaces {
277291
let iface_norm = iface.strip_prefix('\\').unwrap_or(iface);
278-
let iface_short = iface_norm.rsplit('\\').next().unwrap_or(iface_norm);
279-
if iface_norm == ancestor_name || iface_short == ancestor_short {
292+
if iface_norm == ancestor_name {
280293
return true;
281294
}
295+
if !fqn_mode {
296+
let iface_short = iface_norm.rsplit('\\').next().unwrap_or(iface_norm);
297+
if iface_short == ancestor_short {
298+
return true;
299+
}
300+
}
282301
}
283302

284303
// Walk the parent chain.
@@ -290,18 +309,28 @@ fn is_subtype_of(
290309
break;
291310
}
292311
let normalized = name.strip_prefix('\\').unwrap_or(name);
293-
let short = normalized.rsplit('\\').next().unwrap_or(normalized);
294-
if normalized == ancestor_name || short == ancestor_short {
312+
if normalized == ancestor_name {
295313
return true;
296314
}
315+
if !fqn_mode {
316+
let short = normalized.rsplit('\\').next().unwrap_or(normalized);
317+
if short == ancestor_short {
318+
return true;
319+
}
320+
}
297321
// Load the parent to check its interfaces and continue the chain.
298322
if let Some(parent_info) = class_loader(name) {
299323
for iface in &parent_info.interfaces {
300324
let iface_norm = iface.strip_prefix('\\').unwrap_or(iface);
301-
let iface_short = iface_norm.rsplit('\\').next().unwrap_or(iface_norm);
302-
if iface_norm == ancestor_name || iface_short == ancestor_short {
325+
if iface_norm == ancestor_name {
303326
return true;
304327
}
328+
if !fqn_mode {
329+
let iface_short = iface_norm.rsplit('\\').next().unwrap_or(iface_norm);
330+
if iface_short == ancestor_short {
331+
return true;
332+
}
333+
}
305334
}
306335
current_parent = parent_info.parent_class.clone();
307336
} else {

src/completion/variable/raw_type_inference.rs

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
/// - Array literals with element type inference
1010
/// - Known array functions (array_filter, array_map, array_pop, etc.)
1111
/// - Generator yield reverse-inference
12+
/// - `assert($var instanceof ClassName)` narrowing
13+
/// - Inline `/** @var Type */` docblock overrides on assignments
1214
///
1315
/// The primary entry point is [`resolve_variable_assignment_raw_type`],
1416
/// which re-parses the file and returns a PHPStan-style type string
@@ -404,7 +406,26 @@ fn accumulate_assignment_raw_types<'b>(
404406
}
405407
}
406408
Statement::Expression(expr_stmt) => {
409+
// Check for inline `/** @var Type */` override before
410+
// the assignment. This mirrors the logic in
411+
// `walk_statements_for_assignments` so that the raw
412+
// type path also respects @var overrides.
413+
if try_inline_var_override_raw(
414+
expr_stmt.expression,
415+
stmt.span().start.offset as usize,
416+
ctx,
417+
&mut acc,
418+
) {
419+
continue;
420+
}
421+
407422
check_expression_for_raw_type(expr_stmt.expression, ctx, &mut acc);
423+
424+
// Check for `assert($var instanceof ClassName)` after
425+
// the assignment. When found, override the base type
426+
// with the instanceof class name so that hover shows
427+
// the narrowed type.
428+
check_assert_instanceof_for_raw_type(expr_stmt.expression, ctx, &mut acc);
408429
}
409430
_ => {}
410431
}
@@ -413,6 +434,109 @@ fn accumulate_assignment_raw_types<'b>(
413434
acc
414435
}
415436

437+
/// Check for `assert($var instanceof ClassName)` and override the
438+
/// accumulator's base type with the class name when found.
439+
///
440+
/// This enables the raw type path (used by hover) to respect
441+
/// instanceof narrowing via assert statements.
442+
fn check_assert_instanceof_for_raw_type<'b>(
443+
expr: &'b Expression<'b>,
444+
ctx: &VarResolutionCtx<'_>,
445+
acc: &mut AssignmentAccumulator,
446+
) {
447+
// Unwrap parenthesised wrapper on the whole expression.
448+
let expr = match expr {
449+
Expression::Parenthesized(inner) => inner.expression,
450+
other => other,
451+
};
452+
453+
if let Expression::Call(Call::Function(func_call)) = expr
454+
&& let Expression::Identifier(ident) = func_call.function
455+
&& ident.value() == "assert"
456+
&& let Some(first_arg) = func_call.argument_list.arguments.iter().next()
457+
{
458+
let arg_expr = match first_arg {
459+
Argument::Positional(pos) => pos.value,
460+
Argument::Named(named) => named.value,
461+
};
462+
463+
// Extract `$var instanceof ClassName` from the argument.
464+
if let Some(cls_name) = try_extract_instanceof_class(arg_expr, ctx.var_name) {
465+
acc.set_base(cls_name);
466+
}
467+
}
468+
}
469+
470+
/// Try to extract the class name from `$var instanceof ClassName`,
471+
/// handling parenthesisation. Returns `None` when the expression
472+
/// is not an instanceof check for the given variable.
473+
fn try_extract_instanceof_class<'b>(expr: &'b Expression<'b>, var_name: &str) -> Option<String> {
474+
match expr {
475+
Expression::Parenthesized(inner) => {
476+
try_extract_instanceof_class(inner.expression, var_name)
477+
}
478+
Expression::Binary(bin) if bin.operator.is_instanceof() => {
479+
// LHS must be our variable.
480+
let lhs_name = match bin.lhs {
481+
Expression::Variable(Variable::Direct(dv)) => dv.name.to_string(),
482+
_ => return None,
483+
};
484+
if lhs_name != var_name {
485+
return None;
486+
}
487+
// RHS is the class name.
488+
match bin.rhs {
489+
Expression::Identifier(ident) => Some(ident.value().to_string()),
490+
_ => None,
491+
}
492+
}
493+
_ => None,
494+
}
495+
}
496+
497+
/// Try to resolve a variable's type from an inline `/** @var Type */`
498+
/// docblock that immediately precedes an assignment statement.
499+
///
500+
/// Returns `true` when the override was applied (and the caller should
501+
/// skip normal assignment resolution for this statement).
502+
fn try_inline_var_override_raw<'b>(
503+
expr: &'b Expression<'b>,
504+
stmt_start: usize,
505+
ctx: &VarResolutionCtx<'_>,
506+
acc: &mut AssignmentAccumulator,
507+
) -> bool {
508+
// Must be an assignment to our target variable.
509+
let assignment = match expr {
510+
Expression::Assignment(a) if a.operator.is_assign() => a,
511+
_ => return false,
512+
};
513+
let lhs_name = match assignment.lhs {
514+
Expression::Variable(Variable::Direct(dv)) => dv.name.to_string(),
515+
_ => return false,
516+
};
517+
if lhs_name != ctx.var_name {
518+
return false;
519+
}
520+
521+
// Look for a `/** @var Type [$var] */` docblock right before this
522+
// statement.
523+
let (var_type, var_name_opt) = match docblock::find_inline_var_docblock(ctx.content, stmt_start)
524+
{
525+
Some(pair) => pair,
526+
None => return false,
527+
};
528+
529+
// If the annotation includes a variable name, it must match.
530+
if let Some(ref vn) = var_name_opt
531+
&& vn != ctx.var_name
532+
{
533+
return false;
534+
}
535+
536+
acc.set_base(var_type);
537+
true
538+
}
539+
416540
/// Check a single expression for base, incremental key, or push
417541
/// assignments to the target variable, updating the accumulator.
418542
fn check_expression_for_raw_type<'b>(
@@ -425,10 +549,22 @@ fn check_expression_for_raw_type<'b>(
425549
_ => return,
426550
};
427551

552+
// Use the assignment's own start offset as cursor_offset so that
553+
// any recursive variable resolution only considers assignments
554+
// *before* this one. Without this, a self-referential assignment
555+
// like `$numbers['sub_price'] = $numbers['sub_price']->add(…)`
556+
// would infinitely recurse: resolving the RHS `$numbers['sub_price']`
557+
// triggers resolution of `$numbers`, which re-discovers the same
558+
// assignment, resolves its RHS again, and so on until a stack
559+
// overflow crashes the process.
560+
//
561+
// This mirrors the protection in `check_expression_for_assignment`.
562+
let rhs_ctx = ctx.with_cursor_offset(assignment.span().start.offset);
563+
428564
match assignment.lhs {
429565
// ── Base assignment: `$var = expr;` ──
430566
Expression::Variable(Variable::Direct(dv)) if dv.name == ctx.var_name => {
431-
if let Some(raw) = resolve_rhs_raw_type(assignment.rhs, ctx) {
567+
if let Some(raw) = resolve_rhs_raw_type(assignment.rhs, &rhs_ctx) {
432568
acc.set_base(raw);
433569
}
434570
}
@@ -440,7 +576,7 @@ fn check_expression_for_raw_type<'b>(
440576
let key = extract_array_key_text(array_access.index);
441577
// Skip numeric-only keys — they are not string-keyed shape entries.
442578
if key != "mixed" && !key.chars().all(|c| c.is_ascii_digit()) {
443-
let value_type = infer_expression_type_string(assignment.rhs, ctx);
579+
let value_type = infer_expression_type_string(assignment.rhs, &rhs_ctx);
444580
acc.add_incremental_key(key, value_type);
445581
}
446582
}
@@ -450,7 +586,7 @@ fn check_expression_for_raw_type<'b>(
450586
if let Expression::Variable(Variable::Direct(dv)) = array_append.array
451587
&& dv.name == ctx.var_name
452588
{
453-
let value_type = infer_expression_type_string(assignment.rhs, ctx);
589+
let value_type = infer_expression_type_string(assignment.rhs, &rhs_ctx);
454590
acc.add_push_type(value_type);
455591
}
456592
}

0 commit comments

Comments
 (0)