Skip to content

Commit e0721e6

Browse files
committed
narrow types in foreach expr
1 parent c37eb1c commit e0721e6

File tree

10 files changed

+141
-19
lines changed

10 files changed

+141
-19
lines changed

.phpunit.cache/test-results

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

bug-13310.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
function get_array(): array
4+
{
5+
return [];
6+
}
7+
8+
$tests = get_array();
9+
10+
foreach ($tests as $test) {
11+
12+
// information fichiers
13+
$test['information'] = '';
14+
$test['information'] .= $test['a'] ? 'test' : '';
15+
$test['information'] .= $test['b'] ? 'test' : '';
16+
$test['information'] .= $test['c'] ? 'test' : '';
17+
$test['information'] .= $test['d'] ? 'test' : '';
18+
$test['information'] .= $test['e'] ? 'test' : '';
19+
$test['information'] .= $test['f'] ? 'test' : '';
20+
$test['information'] .= $test['g'] ? 'test' : '';
21+
$test['information'] .= $test['h'] ? 'test' : '';
22+
23+
}

src/Analyser/NodeScopeResolver.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,6 +1250,19 @@ private function processStmtNode(
12501250
$exprType = $scope->getType($stmt->expr);
12511251
$isIterableAtLeastOnce = $exprType->isIterableAtLeastOnce();
12521252
if ($exprType->isIterable()->no() || $isIterableAtLeastOnce->maybe()) {
1253+
$foreachType = $this->getForeachType();
1254+
if (
1255+
!$foreachType->isSuperTypeOf($exprType)->yes()
1256+
&& $finalScope->getType($stmt->expr)->equals($foreachType)
1257+
) {
1258+
// restore iteratee type, in case the type was narrowed while entering the foreach
1259+
$finalScope = $finalScope->assignExpression(
1260+
$stmt->expr,
1261+
$exprType,
1262+
$scope->getNativeType($stmt->expr),
1263+
);
1264+
}
1265+
12531266
$finalScope = $finalScope->mergeWith($scope->filterByTruthyValue(new BooleanOr(
12541267
new BinaryOp\Identical(
12551268
$stmt->expr,
@@ -6307,11 +6320,35 @@ private function processVarAnnotation(MutatingScope $scope, array $variableNames
63076320
return $scope;
63086321
}
63096322

6323+
private function getForeachType(): Type
6324+
{
6325+
return TypeCombinator::union(
6326+
new ArrayType(new MixedType(), new MixedType()),
6327+
new ObjectType(Traversable::class),
6328+
);
6329+
}
6330+
63106331
private function enterForeach(MutatingScope $scope, MutatingScope $originalScope, Foreach_ $stmt): MutatingScope
63116332
{
63126333
if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) {
63136334
$scope = $this->processVarAnnotation($scope, [$stmt->expr->name], $stmt);
63146335
}
6336+
6337+
// narrow the iteratee type to those supported by foreach
6338+
$foreachType = $this->getForeachType();
6339+
$scope = $scope->specifyExpressionType(
6340+
$stmt->expr,
6341+
TypeCombinator::intersect(
6342+
$scope->getType($stmt->expr),
6343+
$foreachType,
6344+
),
6345+
TypeCombinator::intersect(
6346+
$scope->getNativeType($stmt->expr),
6347+
$foreachType,
6348+
),
6349+
TrinaryLogic::createYes(),
6350+
);
6351+
63156352
$iterateeType = $originalScope->getType($stmt->expr);
63166353
if (
63176354
($stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name))

test.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
use function PHPStan\Testing\assertType;
4+
5+
function fooBar(mixed $mixed): void {
6+
assertType('mixed', $mixed);
7+
foreach ($mixed as $v) {
8+
assertType('non-empty-array|Traversable', $mixed);
9+
}
10+
assertType('mixed', $mixed);
11+
}

tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5816,7 +5816,7 @@ public static function dataIterable(): array
58165816
'$iterableWithoutTypehint[0]',
58175817
],
58185818
[
5819-
'iterable',
5819+
'array|Traversable',
58205820
'$iterableWithIterableTypehint',
58215821
],
58225822
[
@@ -5828,7 +5828,7 @@ public static function dataIterable(): array
58285828
'$mixed',
58295829
],
58305830
[
5831-
'iterable<Iterables\Bar>',
5831+
'array<Iterables\Bar>|(iterable<Iterables\Bar>&Traversable)',
58325832
'$iterableWithConcreteTypehint',
58335833
],
58345834
[
@@ -5844,7 +5844,7 @@ public static function dataIterable(): array
58445844
'$this->doBar()',
58455845
],
58465846
[
5847-
'iterable<Iterables\Baz>',
5847+
'array<Iterables\Baz>|(iterable<Iterables\Baz>&Traversable)',
58485848
'$this->doBaz()',
58495849
],
58505850
[

tests/PHPStan/Analyser/nsrt/bug-13270a.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ final class HelloWorld
1414
public function test(array $data): void
1515
{
1616
foreach($data as $k => $v) {
17-
assertType('non-empty-array<mixed>', $data);
17+
assertType('non-empty-array', $data);
1818
$data[$k]['a'] = true;
1919
assertType("non-empty-array<(non-empty-array&hasOffsetValue('a', true))|(ArrayAccess&hasOffsetValue('a', true))>", $data);
2020
foreach($data[$k] as $val) {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace Bug13312;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function fooArr(array $arr): void {
8+
assertType('array', $arr);
9+
foreach ($arr as $v) {
10+
assertType('non-empty-array', $arr);
11+
}
12+
assertType('array', $arr);
13+
14+
for ($i = 0; $i < count($arr); ++$i) {
15+
assertType('non-empty-array', $arr);
16+
}
17+
assertType('array', $arr);
18+
}
19+
20+
/** @param list<mixed> $arr */
21+
function foo(array $arr): void {
22+
assertType('list<mixed>', $arr);
23+
foreach ($arr as $v) {
24+
assertType('non-empty-list<mixed>', $arr);
25+
}
26+
assertType('list<mixed>', $arr);
27+
28+
for ($i = 0; $i < count($arr); ++$i) {
29+
assertType('non-empty-list<mixed>', $arr);
30+
}
31+
assertType('list<mixed>', $arr);
32+
}
33+
34+
35+
function fooBar(mixed $mixed): void {
36+
assertType('mixed', $mixed);
37+
foreach ($mixed as $v) {
38+
assertType('array|Traversable', $mixed); // could be non-empty-array|Traversable
39+
}
40+
assertType('mixed', $mixed);
41+
42+
foreach ($mixed as $v) {}
43+
44+
assertType('mixed', $mixed);
45+
}

tests/PHPStan/Analyser/nsrt/composer-array-bug.php

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,26 @@ public function doFoo(): void
1818
if (!empty($this->config['authors'])) {
1919
assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']);
2020
foreach ($this->config['authors'] as $key => $author) {
21-
assertType("mixed~(0|0.0|false|null)", $this->config['authors']);
21+
assertType("array|Traversable", $this->config['authors']);
2222

2323
if (!is_array($author)) {
2424
$this->errors[] = 'authors.'.$key.' : should be an array, '.gettype($author).' given';
25-
assertType("mixed~(0|0.0|false|null)", $this->config['authors']);
25+
assertType("array|Traversable", $this->config['authors']);
2626
unset($this->config['authors'][$key]);
27-
assertType("mixed~(0|0.0|false|null)", $this->config['authors']);
27+
assertType("array|Traversable", $this->config['authors']);
2828
continue;
2929
}
30-
assertType("mixed~(0|0.0|false|null)", $this->config['authors']);
30+
assertType("array|Traversable", $this->config['authors']);
3131
foreach (['homepage', 'email', 'name', 'role'] as $authorData) {
3232
if (isset($author[$authorData]) && !is_string($author[$authorData])) {
3333
$this->errors[] = 'authors.'.$key.'.'.$authorData.' : invalid value, must be a string';
3434
unset($this->config['authors'][$key][$authorData]);
3535
}
3636
}
3737
if (isset($author['homepage'])) {
38-
assertType("mixed~(0|0.0|false|null)", $this->config['authors']);
38+
assertType("array|Traversable", $this->config['authors']);
3939
unset($this->config['authors'][$key]['homepage']);
40-
assertType("array|ArrayAccess|string", $this->config['authors']);
40+
assertType("array|Traversable", $this->config['authors']);
4141
}
4242
if (isset($author['email']) && !filter_var($author['email'], FILTER_VALIDATE_EMAIL)) {
4343
unset($this->config['authors'][$key]['email']);
@@ -47,8 +47,8 @@ public function doFoo(): void
4747
}
4848
}
4949

50-
assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|false|null))", $this->config);
51-
assertType("mixed~(0|0.0|false|null)", $this->config['authors']);
50+
assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config);
51+
assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']);
5252

5353
if (empty($this->config['authors'])) {
5454
unset($this->config['authors']);
@@ -57,7 +57,7 @@ public function doFoo(): void
5757
assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config);
5858
}
5959

60-
assertType('array', $this->config);
60+
assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config);
6161
}
6262
}
6363

tests/PHPStan/Analyser/nsrt/composer-non-empty-array-after-unset.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ public function doFoo()
1515
if (!empty($this->config['authors'])) {
1616
assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']);
1717
foreach ($this->config['authors'] as $key => $author) {
18-
assertType("mixed~(0|0.0|false|null)", $this->config['authors']);
18+
assertType("array|Traversable", $this->config['authors']);
1919
if (!is_array($author)) {
2020
unset($this->config['authors'][$key]);
21-
assertType("mixed~(0|0.0|false|null)", $this->config['authors']);
21+
assertType("array|Traversable", $this->config['authors']);
2222
continue;
2323
}
2424
foreach (['homepage', 'email', 'name', 'role'] as $authorData) {
@@ -33,13 +33,13 @@ public function doFoo()
3333
unset($this->config['authors'][$key]['email']);
3434
}
3535
if (empty($this->config['authors'][$key])) {
36-
assertType("mixed~(0|0.0|false|null)", $this->config['authors']);
36+
assertType("array|Traversable", $this->config['authors']);
3737
unset($this->config['authors'][$key]);
38-
assertType("mixed~(0|0.0|false|null)", $this->config['authors']);
38+
assertType("array|Traversable", $this->config['authors']);
3939
}
40-
assertType("mixed~(0|0.0|false|null)", $this->config['authors']);
40+
assertType("array|Traversable", $this->config['authors']);
4141
}
42-
assertType("mixed~(0|0.0|false|null)", $this->config['authors']);
42+
assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']);
4343
}
4444
}
4545

tests/PHPStan/Rules/Arrays/IterableInForeachRuleTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,9 @@ public function testMixed(bool $checkExplicitMixed, bool $checkImplicitMixed, ar
139139
$this->analyse([__DIR__ . '/data/foreach-mixed.php'], $errors);
140140
}
141141

142+
public function testBug13312(): void
143+
{
144+
$this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13312.php'], []);
145+
}
146+
142147
}

0 commit comments

Comments
 (0)