Skip to content

Commit dab5f1c

Browse files
committed
Fix foreach type narrowing for constant string arrays
- After foreach over constant string arrays like ['need', 'field'], propagate per-element type narrowings (HasOffsetValueType) to variables narrowed inside the loop body - Added logic in NodeScopeResolver post-foreach processing to detect variables whose dim-fetch expressions were narrowed (e.g. via isset + is_string) and apply HasOffsetValueType for each constant array element - Guards against false positives by checking the variable wasn't modified (assigned) in the body and that the dim-fetch narrowing is new (not pre-existing) - New regression test in tests/PHPStan/Analyser/nsrt/bug-11533.php
1 parent 8b96391 commit dab5f1c

2 files changed

Lines changed: 107 additions & 0 deletions

File tree

src/Analyser/NodeScopeResolver.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
use PHPStan\Rules\Properties\ReadWritePropertiesExtensionProvider;
138138
use PHPStan\ShouldNotHappenException;
139139
use PHPStan\TrinaryLogic;
140+
use PHPStan\Type\Accessory\HasOffsetValueType;
140141
use PHPStan\Type\ArrayType;
141142
use PHPStan\Type\ClosureType;
142143
use PHPStan\Type\FileTypeMapper;
@@ -1393,6 +1394,85 @@ public function processStmtNode(
13931394
$finalScope = $finalScope->assignExpression(new ForeachValueByRefExpr($stmt->valueVar), new MixedType(), new MixedType());
13941395
}
13951396

1397+
// Propagate per-element type narrowings from foreach over constant arrays
1398+
if (
1399+
$context->isTopLevel()
1400+
&& count($breakExitPoints) === 0
1401+
&& $isIterableAtLeastOnce->yes()
1402+
&& !$stmt->byRef
1403+
&& $stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name)
1404+
&& $exprType->isConstantArray()->yes()
1405+
) {
1406+
$constantArrays = $exprType->getConstantArrays();
1407+
if (
1408+
count($constantArrays) === 1
1409+
&& count($constantArrays[0]->getValueTypes()) > 0
1410+
&& count($constantArrays[0]->getValueTypes()) <= 32
1411+
) {
1412+
$constantArray = $constantArrays[0];
1413+
$offsetValueTypes = [];
1414+
foreach ($constantArray->getValueTypes() as $valueType) {
1415+
$constantStrings = $valueType->getConstantStrings();
1416+
if (count($constantStrings) === 1) {
1417+
$offsetValueTypes[] = $constantStrings[0];
1418+
continue;
1419+
}
1420+
$offsetValueTypes = [];
1421+
break;
1422+
}
1423+
1424+
if (count($offsetValueTypes) > 0) {
1425+
$bodyEndScope = $finalScopeResult->getScope();
1426+
$loopVar = $stmt->valueVar;
1427+
foreach ($finalScope->getDefinedVariables() as $varName) {
1428+
if ($varName === $loopVar->name) {
1429+
continue;
1430+
}
1431+
$varExpr = new Variable($varName);
1432+
$varType = $finalScope->getType($varExpr);
1433+
if (!$varType->isArray()->yes()) {
1434+
continue;
1435+
}
1436+
1437+
// Skip if the variable was modified (assigned) in the body
1438+
$preLoopVarType = $scope->getType($varExpr);
1439+
if (!$preLoopVarType->equals($varType)) {
1440+
continue;
1441+
}
1442+
1443+
$dimFetch = new ArrayDimFetch($varExpr, $loopVar);
1444+
// Only proceed if the body specifically narrowed $var[$field]
1445+
if (!$bodyEndScope->hasExpressionType($dimFetch)->yes()) {
1446+
continue;
1447+
}
1448+
// Skip if the pre-loop scope already had this expression type
1449+
if ($scope->hasExpressionType($dimFetch)->yes()) {
1450+
continue;
1451+
}
1452+
1453+
$dimFetchType = $bodyEndScope->getType($dimFetch);
1454+
$genericValueType = $varType->getIterableValueType();
1455+
1456+
if ($dimFetchType->equals($genericValueType)) {
1457+
continue;
1458+
}
1459+
1460+
$accessories = [];
1461+
foreach ($offsetValueTypes as $offsetType) {
1462+
$accessories[] = new HasOffsetValueType($offsetType, $dimFetchType);
1463+
}
1464+
$narrowedVarType = TypeCombinator::intersect($varType, ...$accessories);
1465+
$finalScope = $finalScope->assignVariable(
1466+
$varName,
1467+
$narrowedVarType,
1468+
TypeCombinator::intersect($finalScope->getNativeType($varExpr), ...$accessories),
1469+
TrinaryLogic::createYes(),
1470+
);
1471+
}
1472+
}
1473+
}
1474+
}
1475+
13961476
return new InternalStatementResult(
13971477
$finalScope,
13981478
$finalScopeResult->hasYield() || $condResult->hasYield(),
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug11533;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/** @param mixed[] $param */
8+
function hello(array $param): void
9+
{
10+
foreach (['need', 'field'] as $field) {
11+
if (!isset($param[$field]) || !is_string($param[$field])) {
12+
throw new \Exception();
13+
}
14+
}
15+
assertType("non-empty-array<mixed>&hasOffsetValue('field', string)&hasOffsetValue('need', string)", $param);
16+
}
17+
18+
/** @param array<string, mixed> $data */
19+
function helloWithArrayKeyExists(array $data): void
20+
{
21+
foreach (['name', 'email'] as $key) {
22+
if (!array_key_exists($key, $data) || !is_string($data[$key])) {
23+
throw new \Exception();
24+
}
25+
}
26+
assertType("non-empty-array<string, mixed>&hasOffsetValue('email', string)&hasOffsetValue('name', string)", $data);
27+
}

0 commit comments

Comments
 (0)