Skip to content

Commit ce05023

Browse files
committed
Apply inverse condition narrowing after while and do-while loops
1 parent e23a386 commit ce05023

5 files changed

Lines changed: 60 additions & 14 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5050
- **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.
5151
- **Stack overflow on large codebases and large files.** The `analyze` command and files with hundreds of class definitions no longer crash with stack overflows.
5252
- **`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`.
53-
- **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.
53+
- **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).
5454
- **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).
5555
- **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>`).
5656
- **Vendor functions and constants.** Functions and constants defined in vendor packages are now indexed at startup, eliminating false-positive "Function not found" diagnostics.

docs/todo/bugs.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,13 @@ only by this infrastructure limitation.
7070
**Discovered:** SKIP audit of `tests/psalm_assertions/loop_do.php`,
7171
`loop_while.php`, `loop_foreach.php`.
7272

73-
After a loop exits (via condition failure or break), the variable
74-
type is not properly narrowed. Examples:
73+
After a loop exits (via condition failure or break), some variable
74+
types are not properly narrowed. Remaining issues:
7575

76-
- `do { $a = getA(); } while ($a !== null);` — after the loop,
77-
`$a` should be `null` but resolves to `A|null`
78-
- `while ($a = getA()) { ... }` — after the loop, `$a` should
79-
be narrowed by the falsy exit condition
76+
- Cross-namespace class resolution in multi-namespace test files:
77+
when multiple namespaces define a class `A`, property type
78+
resolution picks up the wrong class, preventing nullable
79+
propagation through the loop
8080
- `foreach` loop variable not narrowed away from initial `null`
8181
after non-empty iteration
8282
- Break-in-else branch type not merged with loop variable
@@ -86,8 +86,8 @@ type is not properly narrowed. Examples:
8686
Related to T20 (type narrowing reconciliation engine) and T29
8787
(definite vs possible variable existence tracking).
8888

89-
**Tests:** SKIPs in `tests/psalm_assertions/loop_do.php` (lines
90-
63, 80), `loop_while.php` (lines 39, 91, 115, 152),
89+
**Tests:** SKIPs in `tests/psalm_assertions/loop_do.php` (line 80),
90+
`loop_while.php` (lines 91, 115, 152),
9191
`loop_foreach.php` (lines 81, 128, 156, 188, 208).
9292

9393

src/completion/variable/forward_walk.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1867,7 +1867,36 @@ impl ScopeState {
18671867
pub fn merge_branch(&mut self, other: &ScopeState) {
18681868
for (name, other_types) in &other.locals {
18691869
let entry = self.locals.entry(*name).or_default();
1870-
ResolvedType::extend_unique(entry, other_types.clone());
1870+
1871+
// Merge other_types into entry. When an incoming entry
1872+
// shares a class name with an existing entry but has a
1873+
// broader type_string (e.g. `?A` vs `A`), widen the
1874+
// existing entry's type_string instead of discarding
1875+
// the incoming one. This prevents post-loop merges from
1876+
// losing nullable information.
1877+
for rt in other_types.iter() {
1878+
let mut merged_into_existing = false;
1879+
if let Some(ref rt_cls) = rt.class_info {
1880+
for existing in entry.iter_mut() {
1881+
if let Some(ref ex_cls) = existing.class_info
1882+
&& ex_cls.name == rt_cls.name
1883+
{
1884+
// Same class. If the incoming type is
1885+
// broader, adopt it.
1886+
if existing.type_string != rt.type_string
1887+
&& existing.type_string.is_subset_of(&rt.type_string)
1888+
{
1889+
existing.type_string = rt.type_string.clone();
1890+
}
1891+
merged_into_existing = true;
1892+
break;
1893+
}
1894+
}
1895+
}
1896+
if !merged_into_existing {
1897+
ResolvedType::push_unique(entry, rt.clone());
1898+
}
1899+
}
18711900

18721901
// Remove entries whose type is subsumed by a broader entry.
18731902
// E.g. `string|null` ⊆ `int|string|null` → drop the former.
@@ -6781,6 +6810,11 @@ fn process_while<'b>(while_stmt: &'b While<'b>, scope: &mut ScopeState, ctx: &Fo
67816810
*scope = pre_loop_scope;
67826811
scope.merge_branch(&post_loop);
67836812

6813+
// After the loop, the condition evaluated to false (that's why the
6814+
// loop exited). Apply the inverse of the condition to narrow types.
6815+
// For example: `while ($a) { $a = $a->parent; }` => after loop, $a is null.
6816+
apply_condition_narrowing_inverse(while_stmt.condition, scope, ctx);
6817+
67846818
// Remove synthetic property access keys that were seeded by
67856819
// condition narrowing. These represent narrowed types that only
67866820
// hold inside the loop body (where the condition is true).
@@ -6934,6 +6968,12 @@ fn process_do_while<'b>(dw: &'b DoWhile<'b>, scope: &mut ScopeState, ctx: &Forwa
69346968
walk_body_forward(std::iter::once(dw.statement), scope, ctx);
69356969
}
69366970

6971+
// After the do-while loop, the condition evaluated to false (that's
6972+
// why the loop exited). Apply the inverse of the condition to narrow
6973+
// types. For example: `do { $a = getA(); } while ($a !== null);`
6974+
// => after loop, $a is null.
6975+
apply_condition_narrowing_inverse(dw.condition, scope, ctx);
6976+
69376977
leave_loop(loop_depth);
69386978
}
69396979

@@ -8035,6 +8075,12 @@ fn apply_null_narrowing_inverse<'b>(
80358075
if let Some(var_name) = extract_falsy_check_var(condition) {
80368076
strip_null_from_scope(&var_name, scope);
80378077
}
8078+
// When the condition is a bare `$x` (truthy check), the inverse means
8079+
// $x is falsy. For nullable types (`T|null`), narrow to null.
8080+
// This handles `while ($a) { ... }` => after loop, $a is null.
8081+
if let Some(var_name) = expr_to_var_name(condition) {
8082+
narrow_to_null_in_scope(&var_name, scope);
8083+
}
80388084
// `isset($x)` — inverse (else) means $x was null: narrow to null.
80398085
for var_name in extract_isset_vars(condition) {
80408086
seed_synthetic_key_if_needed(&var_name, scope, ctx);

tests/psalm_assertions/loop_do.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ function takesA(A $a): void {}
6060
$a = $a->a;
6161
} while ($a);
6262

63-
assertType('null', $a); // SKIP — got A|null, loop exit type not narrowed
63+
assertType('null', $a);
6464
}
6565

6666
// Test: doWhileWithMethodCall
@@ -77,6 +77,6 @@ public function getParent(): ?A {
7777
$a = $a->getParent();
7878
} while ($a);
7979

80-
assertType('null', $a); // SKIP — got A, loop exit type not narrowed
80+
assertType('null', $a); // SKIP — cross-namespace class resolution: A resolves to wrong namespace
8181
}
8282

tests/psalm_assertions/loop_while.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ function makeA(): A {
8888
$a = $a->parent;
8989
}
9090

91-
assertType('null', $a); // SKIP
91+
assertType('null', $a); // SKIP — cross-namespace class resolution: A resolves to wrong namespace
9292
}
9393

9494
// Test: objectValueWithAnd
@@ -149,6 +149,6 @@ function takesA(A $a): void {}
149149
$a = $a->a;
150150
};
151151

152-
assertType('null', $a); // SKIP
152+
assertType('null', $a); // SKIP — cross-namespace class resolution: A resolves to wrong namespace
153153
}
154154

0 commit comments

Comments
 (0)