Skip to content

Commit 62e722d

Browse files
phpstan-botclaude
andcommitted
Unroll foreach over small constant arrays to make keys non-optional
When iterating over a constant array like ['b', 'c'] with a foreach loop, all elements are guaranteed to be iterated. The fixed-point loop analysis processes the body with the union type ('b'|'c'), which results in optional keys since only one value is set per iteration. This adds a post-loop unrolling refinement step that processes the body once per element with specific types. This correctly determines that all keys are definitely set after the loop completes, changing the result from array{a: string, b?: int, c?: int} to array{a: string, b: int, c: int}. The unrolling is limited to constant arrays with <= 8 elements, no optional keys, no break statements, and no by-reference iteration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent adac30e commit 62e722d

2 files changed

Lines changed: 102 additions & 1 deletion

File tree

src/Analyser/NodeScopeResolver.php

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ class NodeScopeResolver
187187

188188
private const LOOP_SCOPE_ITERATIONS = 3;
189189
private const GENERALIZE_AFTER_ITERATION = 1;
190+
private const FOREACH_UNROLL_LIMIT = 8;
190191

191192
/** @var array<string, true> filePath(string) => bool(true) */
192193
private array $analysedFiles = [];
@@ -1383,6 +1384,106 @@ public function processStmtNode(
13831384
// get types from finalScope, but don't create new variables
13841385
}
13851386

1387+
if (
1388+
$context->isTopLevel()
1389+
&& $isIterableAtLeastOnce->yes()
1390+
&& count($breakExitPoints) === 0
1391+
&& !$stmt->byRef
1392+
&& $exprType->isConstantArray()->yes()
1393+
&& $stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name)
1394+
&& ($stmt->keyVar === null || ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name)))
1395+
) {
1396+
$constantArraysForUnroll = $exprType->getConstantArrays();
1397+
if (
1398+
count($constantArraysForUnroll) === 1
1399+
&& count($constantArraysForUnroll[0]->getOptionalKeys()) === 0
1400+
&& ($unrollKeyCount = count($constantArraysForUnroll[0]->getKeyTypes())) > 0
1401+
&& $unrollKeyCount <= self::FOREACH_UNROLL_LIMIT
1402+
) {
1403+
$unrolledScope = $scope;
1404+
$unrollSucceeded = true;
1405+
foreach ($constantArraysForUnroll[0]->getKeyTypes() as $i => $keyType) {
1406+
$valueType = $constantArraysForUnroll[0]->getValueTypes()[$i];
1407+
1408+
$iterScope = $unrolledScope->assignVariable(
1409+
$stmt->valueVar->name,
1410+
$valueType,
1411+
$valueType,
1412+
TrinaryLogic::createYes(),
1413+
);
1414+
if ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name)) {
1415+
$iterScope = $iterScope->assignVariable(
1416+
$stmt->keyVar->name,
1417+
$keyType,
1418+
$keyType,
1419+
TrinaryLogic::createYes(),
1420+
);
1421+
}
1422+
1423+
$iterStorage = $storage->duplicate();
1424+
$iterResult = $this->processStmtNodesInternal(
1425+
$stmt, $stmt->stmts, $iterScope, $iterStorage,
1426+
new NoopNodeCallback(), $context->enterDeep(),
1427+
)->filterOutLoopExitPoints();
1428+
1429+
$unrolledScope = $iterResult->getScope();
1430+
foreach ($iterResult->getExitPointsByType(Continue_::class) as $continueExitPoint) {
1431+
$unrolledScope = $unrolledScope->mergeWith($continueExitPoint->getScope());
1432+
}
1433+
1434+
if (
1435+
count($iterResult->getExitPointsByType(Break_::class)) > 0
1436+
|| $iterResult->isAlwaysTerminating()
1437+
) {
1438+
$unrollSucceeded = false;
1439+
break;
1440+
}
1441+
}
1442+
1443+
if ($unrollSucceeded) {
1444+
foreach ($unrolledScope->expressionTypes as $exprString => $holder) {
1445+
if (!str_starts_with($exprString, '$') || str_contains($exprString, '[') || str_contains($exprString, '>')) {
1446+
continue;
1447+
}
1448+
if (!$holder->getCertainty()->yes()) {
1449+
continue;
1450+
}
1451+
$unrolledType = $holder->getType();
1452+
if (!$unrolledType->isConstantArray()->yes()) {
1453+
continue;
1454+
}
1455+
if (!isset($finalScope->expressionTypes[$exprString])) {
1456+
continue;
1457+
}
1458+
$finalHolder = $finalScope->expressionTypes[$exprString];
1459+
$finalType = $finalHolder->getType();
1460+
if (!$finalType->isConstantArray()->yes()) {
1461+
continue;
1462+
}
1463+
1464+
$unrolledArrays = $unrolledType->getConstantArrays();
1465+
$finalArrays = $finalType->getConstantArrays();
1466+
if (
1467+
count($unrolledArrays) === 1
1468+
&& count($finalArrays) === 1
1469+
&& count($unrolledArrays[0]->getOptionalKeys()) < count($finalArrays[0]->getOptionalKeys())
1470+
&& count($unrolledArrays[0]->getKeyTypes()) === count($finalArrays[0]->getKeyTypes())
1471+
) {
1472+
$varName = substr($exprString, 1);
1473+
$varExpr = $holder->getExpr();
1474+
$nativeType = $unrolledScope->getNativeType($varExpr);
1475+
$finalScope = $finalScope->assignVariable(
1476+
$varName,
1477+
$unrolledType,
1478+
$nativeType,
1479+
TrinaryLogic::createYes(),
1480+
);
1481+
}
1482+
}
1483+
}
1484+
}
1485+
}
1486+
13861487
if (!$isIterableAtLeastOnce->no()) {
13871488
$throwPoints = array_merge($throwPoints, $finalScopeResult->getThrowPoints());
13881489
$impurePoints = array_merge($impurePoints, $finalScopeResult->getImpurePoints());

tests/PHPStan/Analyser/nsrt/bug-12665.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public function break(string $s, int $i): array
1313
foreach (['b', 'c'] as $letter) {
1414
$array[$letter] = $i;
1515
}
16-
assertType('array{a: string, b?: int, c?: int}', $array);
16+
assertType('array{a: string, b: int, c: int}', $array);
1717
return $array;
1818
}
1919
}

0 commit comments

Comments
 (0)