Skip to content

Commit 17f5213

Browse files
committed
feat: instanceof narrowing on array access expressions
After `if ($row['page'] instanceof Page)`, the type of $row['page'] inside the if-body is now narrowed to Page. This eliminates false-positive unresolved_member_access diagnostics on patterns like $row['page']->getId() that are guarded by an instanceof check.
1 parent 28f30f8 commit 17f5213

5 files changed

Lines changed: 103 additions & 12 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
128128
- **Infinite loop on array key reassignment patterns.** Files containing `$arr['key'] = f($arr['key'])` or similar read-then-write patterns on the same array key no longer hang the analyzer.
129129
- **Stack overflow on large codebases and large files.** The `analyze` command and files with hundreds of class definitions no longer crash with stack overflows.
130130
- **`analyze` and `fix` commands run at consistent speed regardless of invocation style.** Running from within a project directory is no longer ~8x slower than using `--project-root`.
131-
- **Type narrowing.** Comprehensive fixes to type narrowing: non-exiting `if` branches no longer leak narrowed types into post-merge scope; `is_float()`, `is_null()`, and other `is_*()` guards correctly narrow multi-member unions; negated `instanceof` on nullable unions preserves `null`; `is_object()` narrows to all class types in a union (not just the first); `instanceof` on `mixed` or `object` narrows to the checked type; `=== null` and `== null` narrow to `null` in the truthy branch; `assert($x instanceof Foo)` followed by any `if` block no longer loses the narrowed type; falsy guard clauses strip both `false` and `null`; template-constrained types are expanded before guard filtering. `is_numeric()`, `is_bool()`, and `is_scalar()` guard clauses now narrow union types correctly. `exit` and `die` are recognized as unconditional exits for guard clause narrowing. Property access expressions (`$a->foo`) are now narrowed through chained if/elseif/else conditions using `is_string()`, `instanceof`, and other type guards. Array shape keys are now narrowed through null-check guard clauses and conditional reassignment (e.g. `$a["test"]` resolves to `int` instead of `?int` after `if ($a["test"] === null) { return; }`). After `instanceof` check plus reassignment inside the if-body, the post-merge type collapses to the common parent class instead of producing a redundant `Child|Parent` union. Property access after OR'd `instanceof` checks (`$a instanceof B || $a instanceof C`) now resolves to the union of all branches' property types. After `while` and `do-while` loops exit, the loop condition's inverse is applied to narrow variable types (e.g. `do { $a = $a->next; } while ($a)` narrows `$a` to `null` after the loop; `while ($a instanceof Foo)` narrows `$a` to the excluded type after exit). Branch merging no longer loses nullable information when two branches produce the same class with different nullability (e.g. merging `A` with `A|null` correctly yields `A|null` instead of silently dropping the null).
131+
- **Type narrowing.** Comprehensive fixes to type narrowing: non-exiting `if` branches no longer leak narrowed types into post-merge scope; `is_float()`, `is_null()`, and other `is_*()` guards correctly narrow multi-member unions; negated `instanceof` on nullable unions preserves `null`; `is_object()` narrows to all class types in a union (not just the first); `instanceof` on `mixed` or `object` narrows to the checked type; `=== null` and `== null` narrow to `null` in the truthy branch; `assert($x instanceof Foo)` followed by any `if` block no longer loses the narrowed type; falsy guard clauses strip both `false` and `null`; template-constrained types are expanded before guard filtering. `is_numeric()`, `is_bool()`, and `is_scalar()` guard clauses now narrow union types correctly. `exit` and `die` are recognized as unconditional exits for guard clause narrowing. Property access expressions (`$a->foo`) are now narrowed through chained if/elseif/else conditions using `is_string()`, `instanceof`, and other type guards. Array shape keys are now narrowed through null-check guard clauses and conditional reassignment (e.g. `$a["test"]` resolves to `int` instead of `?int` after `if ($a["test"] === null) { return; }`). Array element access expressions are now narrowed through `instanceof` checks (e.g. `if ($row['page'] instanceof Page)` narrows `$row['page']` to `Page` inside the if-body). After `instanceof` check plus reassignment inside the if-body, the post-merge type collapses to the common parent class instead of producing a redundant `Child|Parent` union. Property access after OR'd `instanceof` checks (`$a instanceof B || $a instanceof C`) now resolves to the union of all branches' property types. After `while` and `do-while` loops exit, the loop condition's inverse is applied to narrow variable types (e.g. `do { $a = $a->next; } while ($a)` narrows `$a` to `null` after the loop; `while ($a instanceof Foo)` narrows `$a` to the excluded type after exit). Branch merging no longer loses nullable information when two branches produce the same class with different nullability (e.g. merging `A` with `A|null` correctly yields `A|null` instead of silently dropping the null).
132132
- **Mixin resolution.** Static method calls on instances of a class with `@mixin` now resolve through the mixin. `@method` and `@property` tags declared on a mixin class are propagated to the consuming class. `$this` return types on mixin methods resolve to the consumer class. `IteratorIterator` is now patched with `@template` parameters and `@mixin TIterator` (matching PHPStan's stubs).
133133
- **Generics.** Constructor generic inference now works through inherited constructors: child classes without their own constructor infer template parameters from the parent's constructor arguments, with correct remapping through multi-level `@extends` chains (including swapped or renamed template parameters). Function-level `@template` parameters bound to generic wrapper types (e.g. `@param Container<TItem> $c`) are inferred from arguments that extend the wrapper class. Method calls are now case-insensitive, matching PHP semantics (e.g. `$obj->getId()` finds `getID()`). Closure literals passed to `@template` parameters are recognised as `Closure`. Class-level template parameters are preserved through chained method calls. Template parameters fall back to their declared bound (or `mixed` when unbounded) when subclasses omit `@extends` or `@use` annotations. Unbound template parameters at call sites resolve to their declared bounds or `mixed` instead of leaking raw names. Method-level templates resolve correctly through generic wrappers and nested call chains. Return type generic arguments are preserved for template substitution, fixing false "expects TRelatedModel, got Translation" diagnostics. Iterating over a subclass that extends a generic collection with scalar type arguments (e.g. `IntCollection extends Collection<int, int>`) now yields the concrete scalar type instead of the raw template parameter name. Calling a method on a union of generic types (e.g. `$var->get()` where `$var` is `C<A>|C<B>`) now resolves to the union of each branch's return type (`A|B`) instead of only the first branch. Empty array literals passed to generic constructors or functions infer `never` for element type parameters (e.g. `new ArrayCollection([])` resolves to `ArrayCollection<never, never>`).
134134
- **Type narrowing.** Comprehensive fixes to type narrowing: non-exiting `if` branches no longer leak narrowed types into post-merge scope; `is_float()`, `is_null()`, and other `is_*()` guards correctly narrow multi-member unions; negated `instanceof` on nullable unions preserves `null`; `is_object()` narrows to all class types in a union (not just the first); `instanceof` on `mixed` or `object` narrows to the checked type; `=== null` and `== null` narrow to `null` in the truthy branch; `assert($x instanceof Foo)` followed by any `if` block no longer loses the narrowed type; falsy guard clauses strip both `false` and `null`; template-constrained types are expanded before guard filtering. `is_numeric()`, `is_bool()`, and `is_scalar()` guard clauses now narrow union types correctly. `exit` and `die` are recognized as unconditional exits for guard clause narrowing. Property access expressions (`$a->foo`) are now narrowed through chained if/elseif/else conditions using `is_string()`, `instanceof`, and other type guards. Array shape keys are now narrowed through null-check guard clauses and conditional reassignment (e.g. `$a["test"]` resolves to `int` instead of `?int` after `if ($a["test"] === null) { return; }`). After `instanceof` check plus reassignment inside the if-body, the post-merge type collapses to the common parent class instead of producing a redundant `Child|Parent` union.

docs/todo/type-inference.md

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -481,14 +481,6 @@ design.
481481
This fixed `Order:646,647` (`json_decode``mixed``is_object`
482482
guard → property access).
483483

484-
**Remaining:** `instanceof` narrowing on array element access
485-
expressions (T20 concern). The narrowing system only matches bare
486-
variable names (`$var`), not subscript expressions (`$arr[0]`).
487-
The `PurchaseFileService` case that originally motivated this item
488-
is now resolved by the `DB::select()` return type patch (B14) combined
489-
with `stdClass` property access suppression, but the general gap
490-
remains for any `$arr[$i] instanceof Foo` pattern.
491-
492484
## T25. Call-site template argument inference for callable parameters
493485

494486
**Impact: Medium · Effort: Medium — partially done**

src/completion/resolver.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ use crate::Backend;
3535
use crate::docblock;
3636
use crate::inheritance::resolve_property_type_hint;
3737
use crate::php_type::PhpType;
38+
use crate::subject_expr::BracketSegment;
3839
use crate::subject_expr::SubjectExpr;
3940
use crate::types::*;
4041
use crate::util::{find_class_by_name, is_self_or_static, resolve_class_keyword};
@@ -672,6 +673,72 @@ fn resolve_target_classes_expr_inner_impl(
672673

673674
// ── Array access on variable or call expression ─────────
674675
SubjectExpr::ArrayAccess { base, segments } => {
676+
// Check if the scope has a narrowed type for this array
677+
// access (e.g. `$row['page']` narrowed via `instanceof`).
678+
if let Some(scope_resolver) = ctx.scope_var_resolver {
679+
// Build the scope key with double-quote format used by
680+
// `expr_to_subject_key` (e.g. `$row["page"]`).
681+
let scope_key = {
682+
let mut k = base.to_subject_text();
683+
for seg in segments {
684+
match seg {
685+
BracketSegment::StringKey(s) => {
686+
k.push_str(&format!("[\"{}\"]", s));
687+
}
688+
BracketSegment::ElementAccess => {
689+
k.push_str("[]");
690+
}
691+
}
692+
}
693+
k
694+
};
695+
let from_scope = scope_resolver(&scope_key);
696+
if !from_scope.is_empty() {
697+
return from_scope;
698+
}
699+
}
700+
// When no scope resolver is available (top-level completion),
701+
// try resolving the full array access key through the forward
702+
// walker. This picks up instanceof narrowing on array elements
703+
// (e.g. `$row['page'] instanceof Page` narrows `$row["page"]`).
704+
if ctx.scope_var_resolver.is_none() && matches!(base.as_ref(), SubjectExpr::Variable(_))
705+
{
706+
let scope_key = {
707+
let mut k = base.to_subject_text();
708+
for seg in segments {
709+
match seg {
710+
BracketSegment::StringKey(s) => {
711+
k.push_str(&format!("[\"{}\"]", s));
712+
}
713+
BracketSegment::ElementAccess => {
714+
k.push_str("[]");
715+
}
716+
}
717+
}
718+
k
719+
};
720+
let dummy_class;
721+
let effective_class = match current_class {
722+
Some(cc) => cc,
723+
None => {
724+
dummy_class = ClassInfo::default();
725+
&dummy_class
726+
}
727+
};
728+
let resolved = crate::completion::variable::resolution::resolve_variable_types(
729+
&scope_key,
730+
effective_class,
731+
all_classes,
732+
ctx.content,
733+
ctx.cursor_offset,
734+
class_loader,
735+
Loaders::with_function(ctx.function_loader),
736+
);
737+
if !resolved.is_empty() {
738+
return resolved;
739+
}
740+
}
741+
675742
// When the base is a call expression (e.g. `$c->items()[0]`),
676743
// resolve the call's raw return type and use it as a candidate
677744
// for array-segment walking. This mirrors the variable path

src/completion/variable/forward_walk.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8803,10 +8803,10 @@ fn collect_condition_property_keys(expr: &Expression<'_>) -> Vec<String> {
88038803

88048804
fn collect_condition_property_keys_inner(expr: &Expression<'_>, keys: &mut Vec<String>) {
88058805
match expr {
8806-
// instanceof: `$a->foo instanceof Foo`
8806+
// instanceof: `$a->foo instanceof Foo` or `$row["page"] instanceof Foo`
88078807
Expression::Binary(bin) if bin.operator.is_instanceof() => {
88088808
if let Some(key) = narrowing::expr_to_subject_key(bin.lhs)
8809-
&& key.contains("->")
8809+
&& (key.contains("->") || key.contains("[\""))
88108810
&& !keys.contains(&key)
88118811
{
88128812
keys.push(key);
@@ -8860,7 +8860,7 @@ fn collect_condition_property_keys_inner(expr: &Expression<'_>, keys: &mut Vec<S
88608860
Argument::Named(named) => named.value,
88618861
};
88628862
if let Some(key) = narrowing::expr_to_subject_key(arg_expr)
8863-
&& key.contains("->")
8863+
&& (key.contains("->") || key.contains("[\""))
88648864
&& !keys.contains(&key)
88658865
{
88668866
keys.push(key);

tests/integration/completion_type_guard_narrowing.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,3 +535,35 @@ async fn test_not_is_array_reassignment_to_array_foreach() {
535535
methods
536536
);
537537
}
538+
539+
// ── instanceof narrowing on array access expressions ────────────────────
540+
541+
#[tokio::test]
542+
async fn test_instanceof_narrows_array_access_expression() {
543+
let backend = create_test_backend();
544+
let uri = Url::parse("file:///instanceof_array_access.php").unwrap();
545+
let text = concat!(
546+
"<?php\n",
547+
"class Page {\n",
548+
" public function getId(): int { return 1; }\n",
549+
"}\n",
550+
"class Table {\n",
551+
" /** @return array<int, array<string, mixed>> */\n",
552+
" public function getRows(): array { return []; }\n",
553+
"}\n",
554+
"function test(Table $table): void {\n",
555+
" foreach ($table->getRows() as $row) {\n",
556+
" if ($row['page'] instanceof Page) {\n",
557+
" $row['page']->\n",
558+
" }\n",
559+
" }\n",
560+
"}\n",
561+
);
562+
let items = complete_at(&backend, &uri, text, 11, 28).await;
563+
let methods = method_names(&items);
564+
assert!(
565+
methods.contains(&"getId"),
566+
"After instanceof narrowing on array access, should see Page methods; got: {:?}",
567+
methods
568+
);
569+
}

0 commit comments

Comments
 (0)