Skip to content

Commit 358fc3f

Browse files
phpstan-botclaude
andcommitted
Unroll foreach over union of constant arrays in tryProcessUnrolledConstantArrayForeach
- Support iterating over a union of multiple ConstantArrayType values (e.g. `list{'a','b'}|list{'x','y'}`) in the foreach unrolling logic - Previously, unrolling was restricted to exactly one constant array (`count($constantArrays) !== 1`), causing unions to fall back to the imprecise iterative processing that merged all possible keys - Now each constant array in the union is unrolled independently with its own chain scope, and the results are merged across arrays - This preserves per-array type precision when building arrays inside foreach loops over constant array unions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a8704ac commit 358fc3f

2 files changed

Lines changed: 162 additions & 77 deletions

File tree

src/Analyser/NodeScopeResolver.php

Lines changed: 105 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -3900,113 +3900,141 @@ private function tryProcessUnrolledConstantArrayForeach(
39003900
return null;
39013901
}
39023902
$constantArrays = $iterateeType->getConstantArrays();
3903-
if (count($constantArrays) !== 1) {
3903+
if (count($constantArrays) === 0) {
39043904
return null;
39053905
}
3906-
$constantArray = $constantArrays[0];
3907-
$keyTypes = $constantArray->getKeyTypes();
3908-
$valueTypes = $constantArray->getValueTypes();
3909-
if (count($keyTypes) === 0 || count($keyTypes) > self::FOREACH_UNROLL_LIMIT) {
3906+
3907+
$totalKeys = 0;
3908+
foreach ($constantArrays as $constantArray) {
3909+
$totalKeys += count($constantArray->getKeyTypes());
3910+
}
3911+
if ($totalKeys === 0 || $totalKeys > self::FOREACH_UNROLL_LIMIT) {
39103912
return null;
39113913
}
39123914

39133915
$nativeIterateeType = $originalScope->getNativeType($stmt->expr);
39143916
$nativeConstantArrays = $nativeIterateeType->getConstantArrays();
3915-
$nativeConstantArray = count($nativeConstantArrays) === 1 ? $nativeConstantArrays[0] : null;
3917+
$matchedNativeArrays = count($nativeConstantArrays) === count($constantArrays) ? $nativeConstantArrays : null;
39163918

3917-
$optionalKeys = array_fill_keys($constantArray->getOptionalKeys(), true);
39183919
$valueVarName = $stmt->valueVar->name;
39193920
$keyVarName = $stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name) ? $stmt->keyVar->name : null;
39203921

3921-
$chainScope = $originalScope;
3922-
$entryScopes = [];
3923-
$breakScopes = [];
3924-
foreach ($keyTypes as $i => $keyType) {
3925-
$valueType = $valueTypes[$i];
3926-
$isOptional = isset($optionalKeys[$i]);
3927-
3928-
$nativeKeyType = $nativeConstantArray !== null && isset($nativeConstantArray->getKeyTypes()[$i])
3929-
? $nativeConstantArray->getKeyTypes()[$i]
3930-
: $keyType;
3931-
$nativeValueType = $nativeConstantArray !== null && isset($nativeConstantArray->getValueTypes()[$i])
3932-
? $nativeConstantArray->getValueTypes()[$i]
3933-
: $valueType;
3934-
3935-
$iterScope = $chainScope->assignVariable(
3936-
$valueVarName,
3937-
$valueType,
3938-
$nativeValueType,
3939-
TrinaryLogic::createYes(),
3940-
);
3941-
$iterScope = $iterScope->assignExpression(
3942-
new OriginalForeachValueExpr($valueVarName),
3943-
$valueType,
3944-
$nativeValueType,
3945-
);
3946-
if ($keyVarName !== null) {
3947-
$iterScope = $iterScope->assignVariable(
3948-
$keyVarName,
3949-
$keyType,
3950-
$nativeKeyType,
3922+
$allBodyScopes = [];
3923+
$allChainScopes = [];
3924+
$allBreakScopes = [];
3925+
3926+
foreach ($constantArrays as $arrayIndex => $constantArray) {
3927+
$keyTypes = $constantArray->getKeyTypes();
3928+
$valueTypes = $constantArray->getValueTypes();
3929+
if (count($keyTypes) === 0) {
3930+
continue;
3931+
}
3932+
3933+
$nativeConstantArray = $matchedNativeArrays !== null ? $matchedNativeArrays[$arrayIndex] : null;
3934+
$optionalKeys = array_fill_keys($constantArray->getOptionalKeys(), true);
3935+
3936+
$chainScope = $originalScope;
3937+
$entryScopes = [];
3938+
3939+
foreach ($keyTypes as $i => $keyType) {
3940+
$valueType = $valueTypes[$i];
3941+
$isOptional = isset($optionalKeys[$i]);
3942+
3943+
$nativeKeyType = $nativeConstantArray !== null && isset($nativeConstantArray->getKeyTypes()[$i])
3944+
? $nativeConstantArray->getKeyTypes()[$i]
3945+
: $keyType;
3946+
$nativeValueType = $nativeConstantArray !== null && isset($nativeConstantArray->getValueTypes()[$i])
3947+
? $nativeConstantArray->getValueTypes()[$i]
3948+
: $valueType;
3949+
3950+
$iterScope = $chainScope->assignVariable(
3951+
$valueVarName,
3952+
$valueType,
3953+
$nativeValueType,
39513954
TrinaryLogic::createYes(),
39523955
);
39533956
$iterScope = $iterScope->assignExpression(
3954-
new OriginalForeachKeyExpr($keyVarName),
3955-
$keyType,
3956-
$nativeKeyType,
3957-
);
3958-
$iterScope = $iterScope->assignExpression(
3959-
new ArrayDimFetch($stmt->expr, $stmt->keyVar),
3957+
new OriginalForeachValueExpr($valueVarName),
39603958
$valueType,
39613959
$nativeValueType,
39623960
);
3963-
}
3961+
if ($keyVarName !== null) {
3962+
$iterScope = $iterScope->assignVariable(
3963+
$keyVarName,
3964+
$keyType,
3965+
$nativeKeyType,
3966+
TrinaryLogic::createYes(),
3967+
);
3968+
$iterScope = $iterScope->assignExpression(
3969+
new OriginalForeachKeyExpr($keyVarName),
3970+
$keyType,
3971+
$nativeKeyType,
3972+
);
3973+
$iterScope = $iterScope->assignExpression(
3974+
new ArrayDimFetch($stmt->expr, $stmt->keyVar),
3975+
$valueType,
3976+
$nativeValueType,
3977+
);
3978+
}
39643979

3965-
$entryScopes[] = $iterScope;
3980+
$entryScopes[] = $iterScope;
39663981

3967-
$iterStorage = $originalStorage->duplicate();
3968-
$bodyResult = $this->processStmtNodesInternal(
3969-
$stmt,
3970-
$stmt->stmts,
3971-
$iterScope,
3972-
$iterStorage,
3973-
new NoopNodeCallback(),
3974-
$context->enterDeep(),
3975-
)->filterOutLoopExitPoints();
3982+
$iterStorage = $originalStorage->duplicate();
3983+
$bodyResult = $this->processStmtNodesInternal(
3984+
$stmt,
3985+
$stmt->stmts,
3986+
$iterScope,
3987+
$iterStorage,
3988+
new NoopNodeCallback(),
3989+
$context->enterDeep(),
3990+
)->filterOutLoopExitPoints();
39763991

3977-
$iterEndScope = $bodyResult->getScope();
3978-
foreach ($bodyResult->getExitPointsByType(Continue_::class) as $continueExitPoint) {
3979-
$iterEndScope = $iterEndScope->mergeWith($continueExitPoint->getScope());
3980-
}
3981-
foreach ($bodyResult->getExitPointsByType(Break_::class) as $breakExitPoint) {
3982-
$breakScopes[] = $breakExitPoint->getScope();
3992+
$iterEndScope = $bodyResult->getScope();
3993+
foreach ($bodyResult->getExitPointsByType(Continue_::class) as $continueExitPoint) {
3994+
$iterEndScope = $iterEndScope->mergeWith($continueExitPoint->getScope());
3995+
}
3996+
foreach ($bodyResult->getExitPointsByType(Break_::class) as $breakExitPoint) {
3997+
$allBreakScopes[] = $breakExitPoint->getScope();
3998+
}
3999+
4000+
if ($isOptional) {
4001+
$chainScope = $iterEndScope->mergeWith($chainScope);
4002+
} else {
4003+
$chainScope = $iterEndScope;
4004+
}
39834005
}
39844006

3985-
if ($isOptional) {
3986-
$chainScope = $iterEndScope->mergeWith($chainScope);
3987-
} else {
3988-
$chainScope = $iterEndScope;
4007+
$arrayBodyScope = $entryScopes[0];
4008+
for ($i = 1, $c = count($entryScopes); $i < $c; $i++) {
4009+
$arrayBodyScope = $arrayBodyScope->mergeWith($entryScopes[$i]);
39894010
}
4011+
if (count($entryScopes) === 1) {
4012+
$arrayBodyScope = $arrayBodyScope->mergeWith($chainScope);
4013+
}
4014+
4015+
$allBodyScopes[] = $arrayBodyScope;
4016+
$allChainScopes[] = $chainScope;
39904017
}
39914018

3992-
$bodyScope = $entryScopes[0];
3993-
for ($i = 1, $c = count($entryScopes); $i < $c; $i++) {
3994-
$bodyScope = $bodyScope->mergeWith($entryScopes[$i]);
4019+
if ($allBodyScopes === []) {
4020+
return null;
4021+
}
4022+
4023+
$bodyScope = $allBodyScopes[0];
4024+
for ($i = 1, $c = count($allBodyScopes); $i < $c; $i++) {
4025+
$bodyScope = $bodyScope->mergeWith($allBodyScopes[$i]);
39954026
}
3996-
if (count($entryScopes) === 1) {
3997-
// For a single-iteration unrolling, the merged entry scope does
3998-
// not include any post-body state. Merge the chain end scope in
3999-
// so that rules analysing the body see that prior iterations
4000-
// (which in this case means: this same iteration, from a rule
4001-
// author's perspective) could have modified variables.
4002-
$bodyScope = $bodyScope->mergeWith($chainScope);
4027+
4028+
$endScope = $allChainScopes[0];
4029+
for ($i = 1, $c = count($allChainScopes); $i < $c; $i++) {
4030+
$endScope = $endScope->mergeWith($allChainScopes[$i]);
40034031
}
40044032

4005-
foreach ($breakScopes as $breakScope) {
4006-
$chainScope = $chainScope->mergeWith($breakScope);
4033+
foreach ($allBreakScopes as $breakScope) {
4034+
$endScope = $endScope->mergeWith($breakScope);
40074035
}
40084036

4009-
return ['bodyScope' => $bodyScope, 'endScope' => $chainScope];
4037+
return ['bodyScope' => $bodyScope, 'endScope' => $endScope];
40104038
}
40114039

40124040
/**
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug7978;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* @phpstan-type TypeArrayCredentialsBasic array{
9+
* username : string,
10+
* password : string,
11+
* }
12+
*
13+
* @phpstan-type TypeArrayCredentialsHeader array{
14+
* app_id : string,
15+
* app_key : string
16+
* }
17+
*
18+
* @phpstan-type TypeArrayCredentials TypeArrayCredentialsBasic|TypeArrayCredentialsHeader
19+
*/
20+
class Test {
21+
22+
const FIELD_SETS = [
23+
'basic' => ['username', 'password'],
24+
'headers' => ['app_id', 'app_key'],
25+
];
26+
27+
public function doSomething(): void
28+
{
29+
foreach (self::FIELD_SETS as $type => $fields) {
30+
$credentials = [];
31+
foreach ($fields as $field) {
32+
$credentials[$field] = 'fake';
33+
}
34+
assertType("array{app_id: 'fake', app_key: 'fake'}|array{username: 'fake', password: 'fake'}", $credentials);
35+
}
36+
}
37+
38+
/** @param list{'username', 'password'}|list{'app_id', 'app_key'} $fields */
39+
public function directUnionForeach(array $fields): void
40+
{
41+
$credentials = [];
42+
foreach ($fields as $field) {
43+
$credentials[$field] = 'fake';
44+
}
45+
assertType("array{app_id: 'fake', app_key: 'fake'}|array{username: 'fake', password: 'fake'}", $credentials);
46+
}
47+
48+
/** @param list{'a', 'b', 'c'}|list{'x'} $fields */
49+
public function differentLengthArrays(array $fields): void
50+
{
51+
$result = [];
52+
foreach ($fields as $field) {
53+
$result[$field] = 1;
54+
}
55+
assertType("array{a: 1, b: 1, c: 1}|array{x: 1}", $result);
56+
}
57+
}

0 commit comments

Comments
 (0)