Skip to content

Commit bef3992

Browse files
zonuexeondrejmirtes
authored andcommitted
Narrow foreach body to non-empty behind an opt-in toggle
Inside a foreach body the iterated expression is provably non-empty, so it can be narrowed (list to non-empty-list, array to non-empty-array) at body entry. Before, this happened only when polluteScopeWithAlwaysIterableForeach was on, so with the flag off (as phpstan-strict-rules sets it) the narrowing disappeared even though the body is only entered when the iteratee is non-empty. The narrowing is gated behind the new feature toggle narrowForeachBodyNonEmpty, off by default. With it off the body scope is built exactly as before, so no behaviour changes. With it on, the body narrows regardless of the flag. The after-loop scope keeps that precision (a value variable can be seen as defined where the iteratee is later proven non-empty), which is why the toggle is opt-in and not enabled under bleedingEdge yet. Closes phpstan/phpstan#13312 Amp-Thread-ID: https://ampcode.com/threads/T-01a19219-6260-7460-a52b-a418f709e4bf
1 parent 091c5a7 commit bef3992

12 files changed

Lines changed: 262 additions & 82 deletions

File tree

‎conf/config.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ parameters:
4747
rawMessageInBaseline: false
4848
reportNestedTooWideType: false
4949
assignToByRefForeachExpr: false
50+
narrowForeachBodyNonEmpty: false
5051
curlSetOptArrayTypes: false
5152
magicDirInInclude: false
5253
checkDateIntervalConstructor: false

‎conf/parametersSchema.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ parametersSchema:
4444
rawMessageInBaseline: bool()
4545
reportNestedTooWideType: bool()
4646
assignToByRefForeachExpr: bool()
47+
narrowForeachBodyNonEmpty: bool()
4748
curlSetOptArrayTypes: bool()
4849
magicDirInInclude: bool()
4950
checkDateIntervalConstructor: bool()

‎src/Analyser/StmtHandler/ForeachHandler.php‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ public function __construct(
9090
private AssignHandler $assignHandler,
9191
#[AutowiredParameter]
9292
private bool $polluteScopeWithAlwaysIterableForeach,
93+
#[AutowiredParameter(ref: '%featureToggles.narrowForeachBodyNonEmpty%')]
94+
private bool $narrowForeachBodyNonEmpty,
9395
)
9496
{
9597
}
@@ -156,9 +158,12 @@ public function processStmt(
156158
// the "iteratee !== []" narrowing every loop pass merges in - composed
157159
// once from the iteratee's result (the same sentinel comparison the
158160
// walked synthetic would delegate to); the walk is the composition's
159-
// miss seam
161+
// miss seam. The loop body is only entered when the iteratee is
162+
// non-empty; narrowForeachBodyNonEmpty narrows it there (list to
163+
// non-empty-list, array to non-empty-array) even with
164+
// polluteScopeWithAlwaysIterableForeach off.
160165
$nonEmptyIterateeScope = $scope;
161-
if ($this->polluteScopeWithAlwaysIterableForeach) {
166+
if ($this->narrowForeachBodyNonEmpty || $this->polluteScopeWithAlwaysIterableForeach) {
162167
$identicalNarrowingHelper = $this->container->getByType(IdenticalNarrowingHelper::class);
163168
$emptyArrayType = new ConstantArrayType([], []);
164169
$nonEmptyTypes = $identicalNarrowingHelper->specifyIdenticalAgainstType(
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser;
4+
5+
use PHPStan\Testing\TypeInferenceTestCase;
6+
use PHPUnit\Framework\Attributes\DataProvider;
7+
8+
class Bug13312NoPolluteTest extends TypeInferenceTestCase
9+
{
10+
11+
public static function dataFileAsserts(): iterable
12+
{
13+
yield from self::gatherAssertTypes(__DIR__ . '/data/bug-13312-no-pollute.php');
14+
}
15+
16+
/**
17+
* @param mixed ...$args
18+
*/
19+
#[DataProvider('dataFileAsserts')]
20+
public function testFileAsserts(
21+
string $assertType,
22+
string $file,
23+
...$args,
24+
): void
25+
{
26+
$this->assertFileAsserts($assertType, $file, ...$args);
27+
}
28+
29+
public static function getAdditionalConfigFiles(): array
30+
{
31+
return [
32+
__DIR__ . '/bug-13312-no-pollute.neon',
33+
];
34+
}
35+
36+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser;
4+
5+
use PHPStan\Testing\TypeInferenceTestCase;
6+
use PHPUnit\Framework\Attributes\DataProvider;
7+
8+
class Bug13312StableTest extends TypeInferenceTestCase
9+
{
10+
11+
public static function dataFileAsserts(): iterable
12+
{
13+
yield from self::gatherAssertTypes(__DIR__ . '/data/bug-13312-stable.php');
14+
}
15+
16+
/**
17+
* @param mixed ...$args
18+
*/
19+
#[DataProvider('dataFileAsserts')]
20+
public function testFileAsserts(
21+
string $assertType,
22+
string $file,
23+
...$args,
24+
): void
25+
{
26+
$this->assertFileAsserts($assertType, $file, ...$args);
27+
}
28+
29+
public static function getAdditionalConfigFiles(): array
30+
{
31+
return [
32+
__DIR__ . '/bug-13312-stable.neon',
33+
];
34+
}
35+
36+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
parameters:
2+
polluteScopeWithAlwaysIterableForeach: false
3+
featureToggles:
4+
narrowForeachBodyNonEmpty: true
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
parameters:
2+
polluteScopeWithAlwaysIterableForeach: false
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug13312NoPollute;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/** @param list<mixed> $arr */
8+
function foo(array $arr): void {
9+
assertType('list<mixed>', $arr);
10+
foreach ($arr as $v) {
11+
assertType('non-empty-list<mixed>', $arr);
12+
}
13+
assertType('list<mixed>', $arr);
14+
15+
for ($i = 0; $i < count($arr); ++$i) {
16+
assertType('non-empty-list<mixed>', $arr);
17+
}
18+
assertType('list<mixed>', $arr);
19+
}
20+
21+
/** @param array<string, int> $arr */
22+
function fooStringKeyed(array $arr): void {
23+
assertType('array<string, int>', $arr);
24+
foreach ($arr as $v) {
25+
assertType('non-empty-array<string, int>', $arr);
26+
}
27+
assertType('array<string, int>', $arr);
28+
}
29+
30+
/** @param list<mixed> $arr */
31+
function fooReassign(array $arr): void {
32+
foreach ($arr as $v) {
33+
$arr = [];
34+
assertType('array{}', $arr);
35+
}
36+
assertType('array{}', $arr);
37+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug13312Stable;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
// With polluteScopeWithAlwaysIterableForeach off and narrowForeachBodyNonEmpty off
8+
// (the stable default), the foreach body does not narrow the iterated expression.
9+
// The for-loop narrowing is a separate mechanism and still applies.
10+
11+
/** @param list<mixed> $arr */
12+
function foo(array $arr): void {
13+
assertType('list<mixed>', $arr);
14+
foreach ($arr as $v) {
15+
assertType('list<mixed>', $arr);
16+
}
17+
assertType('list<mixed>', $arr);
18+
19+
for ($i = 0; $i < count($arr); ++$i) {
20+
assertType('non-empty-list<mixed>', $arr);
21+
}
22+
assertType('list<mixed>', $arr);
23+
}
24+
25+
/** @param array<string, int> $arr */
26+
function fooStringKeyed(array $arr): void {
27+
assertType('array<string, int>', $arr);
28+
foreach ($arr as $v) {
29+
assertType('array<string, int>', $arr);
30+
}
31+
assertType('array<string, int>', $arr);
32+
}

‎tests/PHPStan/Analyser/nsrt/bug-13312.php‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,24 @@ function foo(array $arr): void {
3232
}
3333

3434

35+
/** @param array<string, int> $arr */
36+
function fooStringKeyed(array $arr): void {
37+
assertType('array<string, int>', $arr);
38+
foreach ($arr as $v) {
39+
assertType('non-empty-array<string, int>', $arr);
40+
}
41+
assertType('array<string, int>', $arr);
42+
}
43+
44+
/** @param list<mixed> $arr */
45+
function fooReassign(array $arr): void {
46+
foreach ($arr as $v) {
47+
$arr = [];
48+
assertType('array{}', $arr);
49+
}
50+
assertType('array{}', $arr);
51+
}
52+
3553
function fooBar(mixed $mixed): void {
3654
assertType('mixed', $mixed);
3755
foreach ($mixed as $v) {

0 commit comments

Comments
 (0)