Skip to content

Commit 77a5c31

Browse files
staabmphpstan-bot
authored andcommitted
Preserve array shape when building array in foreach over constant array
- Added post-loop refinement that unrolls the foreach body element-by-element for constant arrays to reconstruct precise array shapes - New method refineForEachScopeForConstantArray in NodeScopeResolver processes each constant array element individually with specific key/value types - New method refineTypesFromConstantArrayForeach in MutatingScope replaces generalized array types with the precise constant array types from the unrolled processing when they are more specific - Updated bug-8924 test expectation to reflect improved precision - New regression test in tests/PHPStan/Analyser/nsrt/bug-13000.php Closes phpstan/phpstan#13000
1 parent ebc5354 commit 77a5c31

4 files changed

Lines changed: 249 additions & 1 deletion

File tree

src/Analyser/MutatingScope.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4388,6 +4388,81 @@ public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope
43884388
);
43894389
}
43904390

4391+
public function refineTypesFromConstantArrayForeach(self $unrolledScope): self
4392+
{
4393+
$expressionTypes = $this->expressionTypes;
4394+
foreach ($unrolledScope->expressionTypes as $exprString => $unrolledHolder) {
4395+
if (!isset($expressionTypes[$exprString])) {
4396+
continue;
4397+
}
4398+
4399+
$currentHolder = $expressionTypes[$exprString];
4400+
$unrolledType = $unrolledHolder->getType();
4401+
$currentType = $currentHolder->getType();
4402+
4403+
// Only refine if the unrolled scope has a more precise type
4404+
if (
4405+
!$unrolledType->isConstantArray()->yes()
4406+
|| !$currentType->isConstantArray()->no()
4407+
|| !$currentType->isArray()->yes()
4408+
|| !$currentType->isSuperTypeOf($unrolledType)->yes()
4409+
) {
4410+
continue;
4411+
}
4412+
4413+
$expressionTypes[$exprString] = new ExpressionTypeHolder(
4414+
$currentHolder->getExpr(),
4415+
$unrolledType,
4416+
$currentHolder->getCertainty(),
4417+
);
4418+
}
4419+
4420+
$nativeTypes = $this->nativeExpressionTypes;
4421+
foreach ($unrolledScope->nativeExpressionTypes as $exprString => $unrolledHolder) {
4422+
if (!isset($nativeTypes[$exprString])) {
4423+
continue;
4424+
}
4425+
4426+
$currentHolder = $nativeTypes[$exprString];
4427+
$unrolledType = $unrolledHolder->getType();
4428+
$currentType = $currentHolder->getType();
4429+
4430+
if (
4431+
!$unrolledType->isConstantArray()->yes()
4432+
|| !$currentType->isConstantArray()->no()
4433+
|| !$currentType->isArray()->yes()
4434+
|| !$currentType->isSuperTypeOf($unrolledType)->yes()
4435+
) {
4436+
continue;
4437+
}
4438+
4439+
$nativeTypes[$exprString] = new ExpressionTypeHolder(
4440+
$currentHolder->getExpr(),
4441+
$unrolledType,
4442+
$currentHolder->getCertainty(),
4443+
);
4444+
}
4445+
4446+
return $this->scopeFactory->create(
4447+
$this->context,
4448+
$this->isDeclareStrictTypes(),
4449+
$this->getFunction(),
4450+
$this->getNamespace(),
4451+
$expressionTypes,
4452+
$nativeTypes,
4453+
$this->conditionalExpressions,
4454+
$this->inClosureBindScopeClasses,
4455+
$this->anonymousFunctionReflection,
4456+
$this->inFirstLevelStatement,
4457+
[],
4458+
[],
4459+
[],
4460+
$this->afterExtractCall,
4461+
$this->parentScope,
4462+
$this->nativeTypesPromoted,
4463+
);
4464+
}
4465+
43914466
public function generalizeWith(self $otherScope): self
43924467
{
43934468
$variableTypeHolders = $this->generalizeVariableTypeHolders(

src/Analyser/NodeScopeResolver.php

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,6 +1347,10 @@ private function processStmtNode(
13471347
$finalScope = $breakExitPoint->getScope()->mergeWith($finalScope);
13481348
}
13491349

1350+
if ($context->isTopLevel()) {
1351+
$finalScope = $this->refineForEachScopeForConstantArray($finalScope, $scope, $originalStorage, $stmt, $context, $breakExitPoints, $arrayComparisonExpr);
1352+
}
1353+
13501354
$exprType = $scope->getType($stmt->expr);
13511355
$hasExpr = $scope->hasExpressionType($stmt->expr);
13521356
if (
@@ -7078,6 +7082,106 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto
70787082
return $this->processVarAnnotation($scope, $vars, $stmt);
70797083
}
70807084

7085+
/**
7086+
* @param InternalStatementExitPoint[] $breakExitPoints
7087+
*/
7088+
private function refineForEachScopeForConstantArray(
7089+
MutatingScope $finalScope,
7090+
MutatingScope $outerScope,
7091+
ExpressionResultStorage $originalStorage,
7092+
Foreach_ $stmt,
7093+
StatementContext $context,
7094+
array $breakExitPoints,
7095+
Expr $arrayComparisonExpr,
7096+
): MutatingScope
7097+
{
7098+
if (count($breakExitPoints) > 0) {
7099+
return $finalScope;
7100+
}
7101+
7102+
if ($stmt->byRef) {
7103+
return $finalScope;
7104+
}
7105+
7106+
if ($stmt->getDocComment() !== null) {
7107+
return $finalScope;
7108+
}
7109+
7110+
if (!$stmt->valueVar instanceof Variable || !is_string($stmt->valueVar->name)) {
7111+
return $finalScope;
7112+
}
7113+
7114+
if (!$stmt->keyVar instanceof Variable || !is_string($stmt->keyVar->name)) {
7115+
return $finalScope;
7116+
}
7117+
7118+
$iterateeType = $outerScope->getType($stmt->expr);
7119+
$nativeIterateeType = $outerScope->getNativeType($stmt->expr);
7120+
$constantArrays = $iterateeType->getConstantArrays();
7121+
$nativeConstantArrays = $nativeIterateeType->getConstantArrays();
7122+
if (
7123+
!$iterateeType->isConstantArray()->yes()
7124+
|| count($constantArrays) !== 1
7125+
|| !$iterateeType->isIterableAtLeastOnce()->yes()
7126+
) {
7127+
return $finalScope;
7128+
}
7129+
7130+
$constantArray = $constantArrays[0];
7131+
$nativeConstantArray = count($nativeConstantArrays) === 1 ? $nativeConstantArrays[0] : null;
7132+
7133+
$keyTypes = $constantArray->getKeyTypes();
7134+
if (count($keyTypes) === 0) {
7135+
return $finalScope;
7136+
}
7137+
7138+
// Process the loop body element-by-element with specific key/value types
7139+
$unrolledScope = $this->polluteScopeWithAlwaysIterableForeach ? $outerScope->filterByTruthyValue($arrayComparisonExpr) : $outerScope;
7140+
foreach ($keyTypes as $i => $keyType) {
7141+
$valueType = $constantArray->getValueTypes()[$i];
7142+
$nativeKeyType = $nativeConstantArray !== null ? $nativeConstantArray->getKeyTypes()[$i] : $keyType;
7143+
$nativeValueType = $nativeConstantArray !== null ? $nativeConstantArray->getValueTypes()[$i] : $valueType;
7144+
7145+
$elementScope = $unrolledScope->assignVariable(
7146+
$stmt->keyVar->name,
7147+
$keyType,
7148+
$nativeKeyType,
7149+
TrinaryLogic::createYes(),
7150+
);
7151+
$elementScope = $elementScope->assignVariable(
7152+
$stmt->valueVar->name,
7153+
$valueType,
7154+
$nativeValueType,
7155+
TrinaryLogic::createYes(),
7156+
);
7157+
7158+
$elementStorage = $originalStorage->duplicate();
7159+
$elementResult = $this->processStmtNodesInternal(
7160+
$stmt,
7161+
$stmt->stmts,
7162+
$elementScope,
7163+
$elementStorage,
7164+
new NoopNodeCallback(),
7165+
$context->enterDeep(),
7166+
)->filterOutLoopExitPoints();
7167+
7168+
if (count($elementResult->getExitPointsByType(Break_::class)) > 0) {
7169+
return $finalScope;
7170+
}
7171+
7172+
if ($elementResult->isAlwaysTerminating()) {
7173+
return $finalScope;
7174+
}
7175+
7176+
$unrolledScope = $elementResult->getScope();
7177+
foreach ($elementResult->getExitPointsByType(Continue_::class) as $continueExitPoint) {
7178+
$unrolledScope = $continueExitPoint->getScope()->mergeWith($unrolledScope);
7179+
}
7180+
}
7181+
7182+
return $finalScope->refineTypesFromConstantArrayForeach($unrolledScope);
7183+
}
7184+
70817185
/**
70827186
* @param callable(Node $node, Scope $scope): void $nodeCallback
70837187
*/
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug13000;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function basicConstantArrayForeach(): void
8+
{
9+
$r = [];
10+
foreach (['a' => '1', 'b' => '2'] as $key => $val) {
11+
$r[$key] = $val;
12+
}
13+
assertType("array{a: '1', b: '2'}", $r);
14+
}
15+
16+
function constantArrayForeachWithTransform(): void
17+
{
18+
$r = [];
19+
foreach (['a' => 'hello', 'b' => 'world'] as $key => $val) {
20+
$r[$key] = strtoupper($val);
21+
}
22+
assertType("array{a: 'HELLO', b: 'WORLD'}", $r);
23+
}
24+
25+
/**
26+
* @param array{a: string, b: string} $input
27+
*/
28+
function constantArrayForeachFromParam(array $input): void
29+
{
30+
$r = [];
31+
foreach ($input as $key => $val) {
32+
$r[$key] = strtoupper($val);
33+
}
34+
assertType("array{a: uppercase-string, b: uppercase-string}", $r);
35+
}
36+
37+
/**
38+
* @return array{a: string, b: string}
39+
*/
40+
function returnTypeIsCompatible(): array
41+
{
42+
$r = [];
43+
foreach (['a' => '1', 'b' => '2'] as $key => $val) {
44+
$r[$key] = $val;
45+
}
46+
assertType("array{a: '1', b: '2'}", $r);
47+
return $r;
48+
}
49+
50+
function integerKeys(): void
51+
{
52+
$r = [];
53+
foreach ([10 => 'x', 20 => 'y'] as $key => $val) {
54+
$r[$key] = $val;
55+
}
56+
assertType("array{10: 'x', 20: 'y'}", $r);
57+
}
58+
59+
/**
60+
* @param array{x: int, y: int, z: int} $coords
61+
*/
62+
function threeKeys(array $coords): void
63+
{
64+
$r = [];
65+
foreach ($coords as $key => $val) {
66+
$r[$key] = $val * 2;
67+
}
68+
assertType("array{x: int, y: int, z: int}", $r);
69+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ function makeValidNumbers(): array
2626
assertType("non-empty-list<-2|-1|1|2|' 1'|' 2'>", $validNumbers);
2727
}
2828

29-
assertType("non-empty-list<-2|-1|1|2|' 1'|' 2'>", $validNumbers);
29+
assertType("array{1, 2, -1, ' 1', -2, ' 2'}", $validNumbers);
3030

3131
return $validNumbers;
3232
}

0 commit comments

Comments
 (0)