Skip to content

Commit ed8ccee

Browse files
committed
More precise tracking $value in foreach
1 parent b72f6ee commit ed8ccee

5 files changed

Lines changed: 155 additions & 3 deletions

File tree

src/Analyser/MutatingScope.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use PHPStan\Node\Expr\GetIterableKeyTypeExpr;
2929
use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr;
3030
use PHPStan\Node\Expr\OriginalForeachKeyExpr;
31+
use PHPStan\Node\Expr\OriginalForeachValueExpr;
3132
use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr;
3233
use PHPStan\Node\Expr\PossiblyImpureCallExpr;
3334
use PHPStan\Node\Expr\PropertyInitializationExpr;
@@ -2353,6 +2354,11 @@ public function enterForeach(self $originalScope, Expr $iteratee, string $valueN
23532354
$nativeValueType,
23542355
TrinaryLogic::createYes(),
23552356
);
2357+
// Track the original foreach value so narrowings applied to the value
2358+
// variable (e.g. is_string($type)) can later be projected back onto the
2359+
// corresponding array dim fetch without being confused by a reassignment
2360+
// ($type = 'foo' invalidates this expression, same as OriginalForeachKeyExpr).
2361+
$scope = $scope->assignExpression(new OriginalForeachValueExpr($valueName), $valueType, $nativeValueType);
23562362
if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) {
23572363
$scope = $scope->assignExpression(
23582364
new IntertwinedVariableByReferenceWithExpr($valueName, $iteratee, new SetExistingOffsetValueTypeExpr(

src/Analyser/NodeScopeResolver.php

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
use PHPStan\Node\Expr\GetIterableKeyTypeExpr;
8080
use PHPStan\Node\Expr\GetIterableValueTypeExpr;
8181
use PHPStan\Node\Expr\OriginalForeachKeyExpr;
82+
use PHPStan\Node\Expr\OriginalForeachValueExpr;
8283
use PHPStan\Node\Expr\PropertyInitializationExpr;
8384
use PHPStan\Node\Expr\TypeExpr;
8485
use PHPStan\Node\Expr\UnsetOffsetExpr;
@@ -1330,10 +1331,27 @@ public function processStmtNode(
13301331
&& $exprType->isConstantArray()->no()
13311332
) {
13321333
$arrayExprDimFetch = new ArrayDimFetch($stmt->expr, $stmt->keyVar);
1334+
$originalValueExpr = null;
1335+
if ($stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name)) {
1336+
$originalValueExpr = new OriginalForeachValueExpr($stmt->valueVar->name);
1337+
}
13331338
$arrayDimFetchLoopTypes = [];
13341339
$keyLoopTypes = [];
13351340
foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) {
1336-
$arrayDimFetchLoopTypes[] = $scopeWithIterableValueType->getType($arrayExprDimFetch);
1341+
$dimFetchType = $scopeWithIterableValueType->getType($arrayExprDimFetch);
1342+
// Condition-based narrowings like `is_string($type)` apply to the value
1343+
// variable but not automatically to the array dim fetch, even though the
1344+
// two describe the same element for a given iteration. If the value var
1345+
// hasn't been reassigned (OriginalForeachValueExpr still tracked) we use
1346+
// the narrowed value-var type in place of the broader dim fetch type so
1347+
// the loop's final array rewrite below picks up the sharper element type.
1348+
if ($originalValueExpr !== null && $scopeWithIterableValueType->hasExpressionType($originalValueExpr)->yes()) {
1349+
$valueVarType = $scopeWithIterableValueType->getType($stmt->valueVar);
1350+
if ($dimFetchType->isSuperTypeOf($valueVarType)->yes()) {
1351+
$dimFetchType = $valueVarType;
1352+
}
1353+
}
1354+
$arrayDimFetchLoopTypes[] = $dimFetchType;
13371355
$keyLoopTypes[] = $scopeWithIterableValueType->getType($stmt->keyVar);
13381356
}
13391357

@@ -1343,8 +1361,15 @@ public function processStmtNode(
13431361
$arrayDimFetchLoopNativeTypes = [];
13441362
$keyLoopNativeTypes = [];
13451363
foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) {
1346-
$arrayDimFetchLoopNativeTypes[] = $scopeWithIterableValueType->getNativeType($arrayExprDimFetch);
1347-
$keyLoopNativeTypes[] = $scopeWithIterableValueType->getNativeType($stmt->keyVar);
1364+
$dimFetchNativeType = $scopeWithIterableValueType->getNativeType($arrayExprDimFetch);
1365+
if ($originalValueExpr !== null && $scopeWithIterableValueType->hasExpressionType($originalValueExpr)->yes()) {
1366+
$valueVarNativeType = $scopeWithIterableValueType->getNativeType($stmt->valueVar);
1367+
if ($dimFetchNativeType->isSuperTypeOf($valueVarNativeType)->yes()) {
1368+
$dimFetchNativeType = $valueVarNativeType;
1369+
}
1370+
}
1371+
$arrayDimFetchLoopNativeTypes[] = $dimFetchNativeType;
1372+
$keyLoopNativeTypes[] = $scopeWithIterableValueType->getType($stmt->keyVar);
13481373
}
13491374

13501375
$arrayDimFetchLoopNativeType = TypeCombinator::union(...$arrayDimFetchLoopNativeTypes);
@@ -3911,6 +3936,11 @@ private function tryProcessUnrolledConstantArrayForeach(
39113936
$nativeValueType,
39123937
TrinaryLogic::createYes(),
39133938
);
3939+
$iterScope = $iterScope->assignExpression(
3940+
new OriginalForeachValueExpr($valueVarName),
3941+
$valueType,
3942+
$nativeValueType,
3943+
);
39143944
if ($keyVarName !== null) {
39153945
$iterScope = $iterScope->assignVariable(
39163946
$keyVarName,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Node\Expr;
4+
5+
use Override;
6+
use PhpParser\Node\Expr;
7+
use PHPStan\Node\VirtualNode;
8+
9+
final class OriginalForeachValueExpr extends Expr implements VirtualNode
10+
{
11+
12+
public Expr\Variable $var;
13+
14+
public function __construct(private string $variableName)
15+
{
16+
parent::__construct([]);
17+
$this->var = new Expr\Variable($this->variableName);
18+
}
19+
20+
public function getVariableName(): string
21+
{
22+
return $this->variableName;
23+
}
24+
25+
#[Override]
26+
public function getType(): string
27+
{
28+
return 'PHPStan_Node_OriginalForeachValueExpr';
29+
}
30+
31+
/**
32+
* @return string[]
33+
*/
34+
#[Override]
35+
public function getSubNodeNames(): array
36+
{
37+
return ['var'];
38+
}
39+
40+
}

src/Node/Printer/Printer.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr;
1616
use PHPStan\Node\Expr\NativeTypeExpr;
1717
use PHPStan\Node\Expr\OriginalForeachKeyExpr;
18+
use PHPStan\Node\Expr\OriginalForeachValueExpr;
1819
use PHPStan\Node\Expr\OriginalPropertyTypeExpr;
1920
use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr;
2021
use PHPStan\Node\Expr\PossiblyImpureCallExpr;
@@ -118,6 +119,11 @@ protected function pPHPStan_Node_OriginalForeachKeyExpr(OriginalForeachKeyExpr $
118119
return sprintf('__phpstanOriginalForeachKey(%s)', $expr->getVariableName());
119120
}
120121

122+
protected function pPHPStan_Node_OriginalForeachValueExpr(OriginalForeachValueExpr $expr): string // phpcs:ignore
123+
{
124+
return sprintf('__phpstanOriginalForeachValue(%s)', $expr->getVariableName());
125+
}
126+
121127
protected function pPHPStan_Node_IntertwinedVariableByReferenceWithExpr(IntertwinedVariableByReferenceWithExpr $expr): string // phpcs:ignore
122128
{
123129
return sprintf('__phpstanIntertwinedVariableByReference(%s, %s, %s)', $expr->getVariableName(), $this->p($expr->getExpr()), $this->p($expr->getAssignedExpr()));
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
namespace ForeachArrayRewrite;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
interface Thing
8+
{
9+
}
10+
11+
class Resolver
12+
{
13+
public function resolve(string $s): Thing
14+
{
15+
throw new \Exception();
16+
}
17+
}
18+
19+
class Tester
20+
{
21+
22+
/**
23+
* @param list<Thing>|list<string> $types
24+
*/
25+
public function narrowAndReplace(array $types, Resolver $resolver): void
26+
{
27+
foreach ($types as $i => $type) {
28+
if (!is_string($type)) {
29+
continue;
30+
}
31+
32+
$types[$i] = $resolver->resolve($type);
33+
}
34+
35+
// Every iteration ends with $types[$i] being a Thing — either the continue
36+
// branch (where $type was already a Thing, so $types[$i] was too) or the
37+
// assignment branch (where $types[$i] was overwritten with a Thing).
38+
assertType('list<ForeachArrayRewrite\\Thing>', $types);
39+
}
40+
41+
/**
42+
* @param list<Thing|string> $types
43+
*/
44+
public function reassignValueVarIsNotAliased(array $types): void
45+
{
46+
foreach ($types as $i => $type) {
47+
// Reassigning $type does not modify $types[$i], so the array's element
48+
// type must be preserved (not narrowed to 'foo').
49+
$type = 'foo';
50+
}
51+
52+
assertType('list<ForeachArrayRewrite\\Thing|string>', $types);
53+
}
54+
55+
/**
56+
* @param list<Thing|string> $types
57+
*/
58+
public function plainNarrowingFlowsThrough(array $types): void
59+
{
60+
foreach ($types as $i => $type) {
61+
if (is_string($type)) {
62+
continue;
63+
}
64+
}
65+
66+
// The loop didn't modify $types, so its shape is unchanged.
67+
assertType('list<ForeachArrayRewrite\\Thing|string>', $types);
68+
}
69+
70+
}

0 commit comments

Comments
 (0)