Skip to content

Commit 1abdb06

Browse files
committed
Fix a couple of var assignment issues.
1 parent 1c81ceb commit 1abdb06

8 files changed

Lines changed: 739 additions & 44 deletions

File tree

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- **`instanceof self/static/parent` narrowing.** Type narrowing with `instanceof self`, `instanceof static`, and `instanceof parent` now works correctly in all contexts (assert, if-blocks, guard clauses, compound conditions). Previously these keywords were ignored because the AST parser produces dedicated node types rather than identifiers, and `parent` was not recognized as a type hint during resolution.
13+
- **Variable assignments inside foreach loops.** Variables conditionally reassigned inside a `foreach` body are now visible after the loop. Previously, `$x = null; foreach (...) { $x = new Foo(); } $x->method();` lost the `Foo` type because the foreach body was only scanned when the cursor was inside it. The `while`, `for`, and `do-while` loops were not affected.
14+
- **Variable-to-variable type propagation.** Assignments like `$found = $pen` now resolve `$found` to the type of `$pen`. Previously, bare variable references on the right-hand side of an assignment were ignored, so patterns like `foreach ($pens as $pen) { $found = $pen; }` left `$found` untyped. This also eliminates false-positive `scalar_member_access` diagnostics when the initial assignment was `$found = null` — the type from the later reassignment now takes precedence.
1315
- **Variable type after reassignment.** When a method parameter is reassigned mid-body (e.g. `$file = $result->getFile()`), subsequent member accesses now resolve against the new type instead of the original parameter type. Previously the diagnostic subject cache reused the first resolution for the entire method scope, producing false-positive "not found" warnings on members that exist only on the reassigned type.
1416
- **Null-safe method chain resolution.** Null-safe method calls (`$obj?->method()`) now resolve the return type correctly for variable type inference, including cross-file chains. Previously `?->` calls were ignored by the RHS resolution pipeline, losing the type for any variable assigned from a null-safe chain.
1517
- **Nullable and generic types in class lookup.** Variables typed as `?ClassName` or `Collection<Item>` now resolve correctly across all code paths. Previously the `?` prefix and generic parameters were not stripped before class lookup, causing the type engine to treat them as unknown types. This fixes completion, hover, go-to-definition, and false-positive diagnostics for any variable whose type uses the nullable shorthand or carries generic arguments.

docs/todo/bugs.md

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -53,39 +53,3 @@ check with exit should produce negative narrowing for subsequent code.
5353
issue that affects any code using the early-return-after-instanceof
5454
pattern.
5555

56-
---
57-
58-
#### B11. Variables initialized to `null` and conditionally reassigned lose their type
59-
60-
| | |
61-
|---|---|
62-
| **Impact** | Medium |
63-
| **Effort** | Medium |
64-
65-
When a variable is initialized as `$x = null` and then conditionally
66-
reassigned inside a loop or branch (e.g. `$x = $transaction`), PHPantom
67-
resolves `$x` to `null` at the usage site even after a truthiness guard
68-
like `if (!$x) { continue; }` or `if ($x !== null)`.
69-
70-
Three common patterns:
71-
72-
1. **Null-init + loop assignment + guard:**
73-
`$x = null; foreach (...) { if (cond) { $x = $expr; } } if ($x) { $x->method(); }`
74-
75-
2. **Null coalesce + guard:**
76-
`$x = $arr[$key] ?? null; if (!$x) { continue; } $x->property;`
77-
78-
3. **assertNotNull:**
79-
`$day = $arr['key'] ?? null; self::assertNotNull($day); $day->from;`
80-
81-
The root cause is that PHPantom's variable resolution picks the first
82-
(or dominant) assignment and does not consider all assignment sites to
83-
build a union type. For pattern 1, only the `= null` is seen. For
84-
patterns 2 and 3, the `?? null` makes the type nullable but the
85-
subsequent guard or assertion is not recognized as narrowing.
86-
87-
Pattern 3 overlaps with custom assert narrowing (`@phpstan-assert`) which
88-
requires recognizing `assertNotNull` as a type guard.
89-
90-
**Triage count:** ~8 scalar_member_access diagnostics in luxplus/shared
91-
(AltapayGateway, PCNService, CustomerService, CoolrunnerClientTest).

example.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,37 @@ public function demo(): void
458458
}
459459

460460

461+
// ── Null-Init + Conditional Reassignment ────────────────────────────────────
462+
463+
class NullInitReassignDemo
464+
{
465+
/** @param list<Pen> $pens */
466+
public function demo(array $pens): void
467+
{
468+
// Pattern 1: null-init + foreach reassignment + truthiness guard
469+
$found = null;
470+
foreach ($pens as $pen) {
471+
if ($pen->color() === 'blue') {
472+
$found = $pen;
473+
}
474+
}
475+
if ($found) {
476+
$found->write(); // Pen from foreach reassignment
477+
}
478+
479+
// Pattern 2: null-coalesce + guard inside foreach
480+
/** @var array<string, Pen> $lookup */
481+
$lookup = getUnknownValue();
482+
$keys = ['a', 'b'];
483+
foreach ($keys as $key) {
484+
$item = $lookup[$key] ?? null;
485+
if (!$item) { continue; }
486+
$item->write(); // Pen from array access via coalesce
487+
}
488+
}
489+
}
490+
491+
461492
// ── Foreach & Array Access ──────────────────────────────────────────────────
462493

463494
class ForeachArrayAccessDemo
@@ -4389,6 +4420,17 @@ function runDemoAssertions(): void
43894420
assert(StaticAssert::isRock(new Rock()) === true, 'StaticAssert::isRock(Rock) must return true');
43904421
assert(StaticAssert::isNotRock(new Banana()) === true, 'StaticAssert::isNotRock(Banana) must return true');
43914422

4423+
// ── Null-init + foreach reassignment (B11) ──────────────────────────
4424+
$pens = [new Pen('blue'), new Pen('red')];
4425+
$found = null;
4426+
foreach ($pens as $pen) {
4427+
if ($pen->color() === 'blue') {
4428+
$found = $pen;
4429+
}
4430+
}
4431+
assert($found instanceof Pen, 'Null-init + foreach reassign must resolve to Pen');
4432+
assert(method_exists($found, 'write'), 'Pen from foreach must have write()');
4433+
43924434
// ── instanceof self/static/parent ───────────────────────────────────
43934435
$sedan = new ScaffoldingSedan();
43944436
assert($sedan instanceof ScaffoldingMotor, 'ScaffoldingSedan must extend ScaffoldingMotor');

src/completion/variable/raw_type_inference.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,48 @@ fn resolve_rhs_raw_type<'b>(rhs: &'b Expression<'b>, ctx: &VarResolutionCtx<'_>)
839839
}
840840
combined
841841
}
842+
// ── Bare variable: `$a = $b` ────────────────────────────────
843+
// Resolve the RHS variable's raw type so that hover shows the
844+
// propagated type. Try the raw-type accumulator first (handles
845+
// regular assignments); fall back to the completion pipeline
846+
// which also handles foreach value/key bindings.
847+
Expression::Variable(Variable::Direct(dv)) => {
848+
let var_name = dv.name.to_string();
849+
// Guard: never recurse into the same variable.
850+
if var_name == ctx.var_name {
851+
return None;
852+
}
853+
// Try raw type resolution first (cheap, same pipeline).
854+
if let Some(raw) = resolve_variable_assignment_raw_type(
855+
&var_name,
856+
ctx.content,
857+
ctx.cursor_offset,
858+
Some(ctx.current_class),
859+
ctx.all_classes,
860+
ctx.class_loader,
861+
ctx.function_loader,
862+
) {
863+
return Some(raw);
864+
}
865+
// Fall back to the completion pipeline which handles foreach
866+
// value/key bindings and other sources the raw-type pipeline
867+
// cannot reach.
868+
let classes = super::resolution::resolve_variable_types(
869+
&var_name,
870+
ctx.current_class,
871+
ctx.all_classes,
872+
ctx.content,
873+
ctx.cursor_offset,
874+
ctx.class_loader,
875+
ctx.function_loader,
876+
);
877+
if !classes.is_empty() {
878+
let names: Vec<&str> = classes.iter().map(|c| c.name.as_str()).collect();
879+
return Some(names.join("|"));
880+
}
881+
// Last resort: docblock scan (e.g. `@var` inline annotation).
882+
super::foreach_resolution::extract_rhs_iterable_raw_type(rhs, ctx)
883+
}
842884
// ── Call / property access — delegate to iterable extractor,
843885
// with a source-scan fallback for standalone function calls
844886
// when no `function_loader` is available. ──

src/completion/variable/resolution.rs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,7 +1053,10 @@ fn walk_foreach_statement<'b>(
10531053
) {
10541054
let body_span = foreach.body.span();
10551055
let header_start = foreach.foreach.span().start.offset;
1056-
if ctx.cursor_offset >= header_start && ctx.cursor_offset <= body_span.end.offset {
1056+
let cursor_inside =
1057+
ctx.cursor_offset >= header_start && ctx.cursor_offset <= body_span.end.offset;
1058+
1059+
if cursor_inside {
10571060
// ── Foreach value/key type from generic iterables ──
10581061
// When the variable we're resolving is the foreach
10591062
// *value* variable, try to infer its type from the
@@ -1081,14 +1084,21 @@ fn walk_foreach_statement<'b>(
10811084
conditional,
10821085
);
10831086
super::foreach_resolution::try_resolve_foreach_key_type(foreach, ctx, results, conditional);
1087+
}
10841088

1085-
match &foreach.body {
1086-
ForeachBody::Statement(inner) => {
1087-
check_statement_for_assignments(inner, ctx, results, true);
1088-
}
1089-
ForeachBody::ColonDelimited(body) => {
1090-
walk_statements_for_assignments(body.statements.iter(), ctx, results, true);
1091-
}
1089+
// Always walk the foreach body for variable assignments, even when
1090+
// the cursor is after the foreach. A foreach body may execute zero
1091+
// or more times, so any assignment inside is conditional.
1092+
//
1093+
// Without this, `$x = null; foreach (...) { $x = new Foo(); }
1094+
// $x->method();` would lose the `Foo` assignment because the body
1095+
// was only walked when the cursor was inside the foreach (B11).
1096+
match &foreach.body {
1097+
ForeachBody::Statement(inner) => {
1098+
check_statement_for_assignments(inner, ctx, results, true);
1099+
}
1100+
ForeachBody::ColonDelimited(body) => {
1101+
walk_statements_for_assignments(body.statements.iter(), ctx, results, true);
10921102
}
10931103
}
10941104
}

src/completion/variable/rhs_resolution.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,28 @@ pub(in crate::completion) fn resolve_rhs_expression<'b>(
127127
}
128128
vec![]
129129
}
130+
// ── Bare variable: `$a = $b` ────────────────────────────────
131+
// Resolve the RHS variable's type by walking assignments before
132+
// this point. The caller (`check_expression_for_assignment`)
133+
// already set `ctx.cursor_offset` to the assignment's start
134+
// offset, so the recursive resolution only considers
135+
// assignments *before* the current one, preventing cycles.
136+
Expression::Variable(Variable::Direct(dv)) => {
137+
let rhs_var = dv.name.to_string();
138+
// Guard: never recurse into the same variable (self-assignment).
139+
if rhs_var == ctx.var_name {
140+
return vec![];
141+
}
142+
super::resolution::resolve_variable_types(
143+
&rhs_var,
144+
ctx.current_class,
145+
ctx.all_classes,
146+
ctx.content,
147+
ctx.cursor_offset,
148+
ctx.class_loader,
149+
ctx.function_loader,
150+
)
151+
}
130152
_ => vec![],
131153
}
132154
}

src/diagnostics/unknown_members.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3623,4 +3623,81 @@ class Service {
36233623
"expected exactly one 'onlyOnA' diagnostic (after reassignment), got: {relevant:?}"
36243624
);
36253625
}
3626+
3627+
/// B11: `$found = null; foreach (...) { $found = $pen; } $found->write()`
3628+
/// must not produce a scalar_member_access diagnostic when the foreach
3629+
/// value variable has a known type.
3630+
#[test]
3631+
fn no_false_positive_null_init_foreach_var_to_var_reassign() {
3632+
let php = r#"<?php
3633+
class Pen {
3634+
public function write(): void {}
3635+
public function color(): string { return ''; }
3636+
}
3637+
class Svc {
3638+
/** @param list<Pen> $pens */
3639+
public function find(array $pens): void {
3640+
$found = null;
3641+
foreach ($pens as $pen) {
3642+
if ($pen->color() === 'blue') {
3643+
$found = $pen;
3644+
}
3645+
}
3646+
if ($found) {
3647+
$found->write();
3648+
}
3649+
}
3650+
}
3651+
"#;
3652+
let backend = Backend::new_test();
3653+
let diags = collect(&backend, "file:///test.php", php);
3654+
let scalar_diags: Vec<_> = diags
3655+
.iter()
3656+
.filter(|d| d.code == Some(NumberOrString::String("scalar_member_access".to_string())))
3657+
.collect();
3658+
assert!(
3659+
scalar_diags.is_empty(),
3660+
"should not flag scalar_member_access on $found->write() after foreach reassign, got: {scalar_diags:?}"
3661+
);
3662+
let unknown_diags: Vec<_> = diags
3663+
.iter()
3664+
.filter(|d| d.message.contains("write"))
3665+
.collect();
3666+
assert!(
3667+
unknown_diags.is_empty(),
3668+
"should not flag unknown member 'write' on $found after foreach reassign, got: {unknown_diags:?}"
3669+
);
3670+
}
3671+
3672+
/// B11: direct instantiation inside foreach body (no var-to-var).
3673+
#[test]
3674+
fn no_false_positive_null_init_foreach_direct_reassign() {
3675+
let php = r#"<?php
3676+
class Transaction {
3677+
public function commit(): void {}
3678+
}
3679+
class Svc {
3680+
/** @param list<string> $items */
3681+
public function process(array $items): void {
3682+
$tx = null;
3683+
foreach ($items as $item) {
3684+
$tx = new Transaction();
3685+
}
3686+
if ($tx) {
3687+
$tx->commit();
3688+
}
3689+
}
3690+
}
3691+
"#;
3692+
let backend = Backend::new_test();
3693+
let diags = collect(&backend, "file:///test.php", php);
3694+
let bad_diags: Vec<_> = diags
3695+
.iter()
3696+
.filter(|d| d.message.contains("commit") || d.message.contains("null"))
3697+
.collect();
3698+
assert!(
3699+
bad_diags.is_empty(),
3700+
"should not flag commit() or scalar null after foreach reassign, got: {bad_diags:?}"
3701+
);
3702+
}
36263703
}

0 commit comments

Comments
 (0)