Skip to content

Commit 7cfb7ea

Browse files
committed
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 and not enabled under bleedingEdge. With it off the body scope is built exactly as before, so no behaviour changes. With it on, the body narrows regardless of polluteScopeWithAlwaysIterableForeach; the narrowing also sharpens the after-loop scope, so a value variable can be seen as defined where the iteratee is later proven non-empty. That is why the toggle stays opt-in for now. Closes phpstan/phpstan#13312
1 parent 19151d3 commit 7cfb7ea

15 files changed

Lines changed: 181 additions & 1 deletion

‎conf/config.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ parameters:
4646
rawMessageInBaseline: false
4747
reportNestedTooWideType: false
4848
assignToByRefForeachExpr: false
49+
narrowForeachBodyNonEmpty: false
4950
curlSetOptArrayTypes: false
5051
magicDirInInclude: false
5152
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/NodeScopeResolver.php‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,8 @@ public function __construct(
263263
private readonly bool $polluteScopeWithLoopInitialAssignments,
264264
#[AutowiredParameter]
265265
private readonly bool $polluteScopeWithAlwaysIterableForeach,
266+
#[AutowiredParameter(ref: '%featureToggles.narrowForeachBodyNonEmpty%')]
267+
private readonly bool $narrowForeachBodyNonEmpty,
266268
#[AutowiredParameter]
267269
private readonly bool $polluteScopeWithBlock,
268270
#[AutowiredParameter(ref: '%exceptions.implicitThrows%')]
@@ -1511,7 +1513,13 @@ public function processStmtNode(
15111513
$originalStorage = $storage;
15121514
$unrolledEndScope = null;
15131515
$unrolledTotalKeys = null;
1514-
$iterateeScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope;
1516+
// The loop body is only entered when the iteratee is non-empty. Under
1517+
// narrowForeachBodyNonEmpty we narrow it there (list to non-empty-list,
1518+
// array to non-empty-array) even with polluteScopeWithAlwaysIterableForeach
1519+
// off; with the toggle off the body scope is unchanged.
1520+
$iterateeScope = $this->narrowForeachBodyNonEmpty || $this->polluteScopeWithAlwaysIterableForeach
1521+
? $scope->filterByTruthyValue($arrayComparisonExpr)
1522+
: $scope;
15151523
if ($context->isTopLevel()) {
15161524
$storage = $originalStorage->duplicate();
15171525

‎src/Testing/RuleTestCase.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver
123123
self::createScopeFactory($reflectionProvider, $typeSpecifier),
124124
$this->shouldPolluteScopeWithLoopInitialAssignments(),
125125
$this->shouldPolluteScopeWithAlwaysIterableForeach(),
126+
self::getContainer()->getParameter('featureToggles')['narrowForeachBodyNonEmpty'],
126127
self::getContainer()->getParameter('polluteScopeWithBlock'),
127128
self::getContainer()->getParameter('exceptions')['implicitThrows'],
128129
$this->shouldTreatPhpDocTypesAsCertain(),

‎src/Testing/TypeInferenceTestCase.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver
9898
self::createScopeFactory($reflectionProvider, $typeSpecifier),
9999
$container->getParameter('polluteScopeWithLoopInitialAssignments'),
100100
$container->getParameter('polluteScopeWithAlwaysIterableForeach'),
101+
$container->getParameter('featureToggles')['narrowForeachBodyNonEmpty'],
101102
$container->getParameter('polluteScopeWithBlock'),
102103
$container->getParameter('exceptions')['implicitThrows'],
103104
$container->getParameter('treatPhpDocTypesAsCertain'),

‎tests/PHPStan/Analyser/AnalyserTest.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,7 @@ private function createAnalyser(): Analyser
839839
self::createScopeFactory($reflectionProvider, $typeSpecifier),
840840
false,
841841
true,
842+
false,
842843
true,
843844
true,
844845
$this->shouldTreatPhpDocTypesAsCertain(),
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+
}

‎tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver
143143
self::createScopeFactory($reflectionProvider, $typeSpecifier),
144144
$this->shouldPolluteScopeWithLoopInitialAssignments(),
145145
$this->shouldPolluteScopeWithAlwaysIterableForeach(),
146+
self::getContainer()->getParameter('featureToggles')['narrowForeachBodyNonEmpty'],
146147
self::getContainer()->getParameter('polluteScopeWithBlock'),
147148
self::getContainer()->getParameter('exceptions')['implicitThrows'],
148149
$this->shouldTreatPhpDocTypesAsCertain(),

‎tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver
7676
self::createScopeFactory($reflectionProvider, $typeSpecifier),
7777
$container->getParameter('polluteScopeWithLoopInitialAssignments'),
7878
$container->getParameter('polluteScopeWithAlwaysIterableForeach'),
79+
$container->getParameter('featureToggles')['narrowForeachBodyNonEmpty'],
7980
$container->getParameter('polluteScopeWithBlock'),
8081
$container->getParameter('exceptions')['implicitThrows'],
8182
$container->getParameter('treatPhpDocTypesAsCertain'),

0 commit comments

Comments
 (0)