Skip to content

Commit 9655055

Browse files
committed
Fix phpstan/phpstan#14201: Narrow array type when element from reset/current/end is narrowed
- Added conditional expression holders in AssignHandler for reset(), current(), end() - When $first = reset($items) and $first is later narrowed via instanceof, $items value type is narrowed too - New regression test in tests/PHPStan/Analyser/nsrt/bug-14201.php
1 parent 6bac0de commit 9655055

2 files changed

Lines changed: 131 additions & 0 deletions

File tree

src/Analyser/ExprHandler/AssignHandler.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
use PHPStan\Type\Accessory\AccessoryArrayListType;
4949
use PHPStan\Type\Accessory\HasOffsetValueType;
5050
use PHPStan\Type\Accessory\NonEmptyArrayType;
51+
use PHPStan\Type\ArrayType;
5152
use PHPStan\Type\Constant\ConstantArrayType;
5253
use PHPStan\Type\Constant\ConstantIntegerType;
5354
use PHPStan\Type\Constant\ConstantStringType;
@@ -60,6 +61,7 @@
6061
use PHPStan\Type\Type;
6162
use PHPStan\Type\TypeCombinator;
6263
use PHPStan\Type\TypeUtils;
64+
use PHPStan\Type\UnionType;
6365
use TypeError;
6466
use function array_key_last;
6567
use function array_merge;
@@ -281,6 +283,13 @@ public function processAssignVar(
281283
$conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType);
282284
}
283285

286+
$conditionalExpressions = $this->processArrayValueExtractionForConditionalExpressions(
287+
$scope,
288+
$var->name,
289+
$conditionalExpressions,
290+
$assignedExpr,
291+
);
292+
284293
$nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage);
285294
$scope = $scope->assignVariable($var->name, $type, $scope->getNativeType($assignedExpr), TrinaryLogic::createYes());
286295
foreach ($conditionalExpressions as $exprString => $holders) {
@@ -1076,4 +1085,69 @@ private function isSameVariable(Expr $a, Expr $b): bool
10761085
return false;
10771086
}
10781087

1088+
/**
1089+
* @param array<string, ConditionalExpressionHolder[]> $conditionalExpressions
1090+
* @return array<string, ConditionalExpressionHolder[]>
1091+
*/
1092+
private function processArrayValueExtractionForConditionalExpressions(
1093+
Scope $scope,
1094+
string $variableName,
1095+
array $conditionalExpressions,
1096+
Expr $assignedExpr,
1097+
): array
1098+
{
1099+
if (
1100+
!$assignedExpr instanceof Expr\FuncCall
1101+
|| !$assignedExpr->name instanceof Name
1102+
|| !in_array($assignedExpr->name->toLowerString(), ['reset', 'current', 'end'], true)
1103+
|| count($assignedExpr->getArgs()) < 1
1104+
) {
1105+
return $conditionalExpressions;
1106+
}
1107+
1108+
$arrayArg = $assignedExpr->getArgs()[0]->value;
1109+
if (!$arrayArg instanceof Variable || !is_string($arrayArg->name)) {
1110+
return $conditionalExpressions;
1111+
}
1112+
1113+
$arrayArgType = $scope->getType($arrayArg);
1114+
if (!$arrayArgType->isArray()->yes()) {
1115+
return $conditionalExpressions;
1116+
}
1117+
1118+
$valueType = $arrayArgType->getIterableValueType();
1119+
if (!$valueType instanceof UnionType) {
1120+
return $conditionalExpressions;
1121+
}
1122+
1123+
$valueTypeMembers = $valueType->getTypes();
1124+
if (count($valueTypeMembers) < 2) {
1125+
return $conditionalExpressions;
1126+
}
1127+
1128+
$arrayExprString = '$' . $arrayArg->name;
1129+
$keyType = $arrayArgType->getIterableKeyType();
1130+
1131+
foreach ($valueTypeMembers as $memberType) {
1132+
$narrowedArrayType = TypeCombinator::intersect(
1133+
$arrayArgType,
1134+
new ArrayType($keyType, $memberType),
1135+
);
1136+
1137+
$holder = new ConditionalExpressionHolder(
1138+
['$' . $variableName => ExpressionTypeHolder::createYes(
1139+
new Variable($variableName),
1140+
$memberType,
1141+
)],
1142+
ExpressionTypeHolder::createYes(
1143+
$arrayArg,
1144+
$narrowedArrayType,
1145+
),
1146+
);
1147+
$conditionalExpressions[$arrayExprString][$holder->getKey()] = $holder;
1148+
}
1149+
1150+
return $conditionalExpressions;
1151+
}
1152+
10791153
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14201;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
class Foo { public function __construct(public string $fooName) {}}
10+
class Bar { public function __construct(public string $barName) {}}
11+
12+
class HelloWorld
13+
{
14+
/**
15+
* @param Foo[]|Bar[] $items
16+
*/
17+
public function doitMatch(array $items): void
18+
{
19+
if ([] === $items) {return; }
20+
21+
$first = reset($items);
22+
match (true) {
23+
$first instanceOf Foo => array_map(function ($i) {
24+
assertType('Bug14201\Foo', $i);
25+
return $i->fooName;
26+
}, $items),
27+
$first instanceOf Bar => array_map(function ($i) {
28+
assertType('Bug14201\Bar', $i);
29+
return $i->barName;
30+
}, $items),
31+
default => throw new \RuntimeException('None of Foo nor Bar')
32+
};
33+
}
34+
35+
/**
36+
* @param Foo[]|Bar[] $items
37+
*/
38+
public function doitIf(array $items): void
39+
{
40+
if ([] === $items) {return; }
41+
42+
$first = reset($items);
43+
if ($first instanceof Foo) {
44+
assertType('non-empty-array<Bug14201\Foo>', $items);
45+
array_map(function ($i) {
46+
assertType('Bug14201\Foo', $i);
47+
return $i->fooName;
48+
}, $items);
49+
} elseif ($first instanceof Bar) {
50+
assertType('non-empty-array<Bug14201\Bar>', $items);
51+
array_map(function ($i) {
52+
assertType('Bug14201\Bar', $i);
53+
return $i->barName;
54+
}, $items);
55+
}
56+
}
57+
}

0 commit comments

Comments
 (0)