|
144 | 144 | use PHPStan\ShouldNotHappenException; |
145 | 145 | use PHPStan\TrinaryLogic; |
146 | 146 | use PHPStan\Type\ClosureType; |
| 147 | +use PHPStan\Type\Constant\ConstantArrayType; |
147 | 148 | use PHPStan\Type\Constant\ConstantIntegerType; |
148 | 149 | use PHPStan\Type\Constant\ConstantStringType; |
149 | 150 | use PHPStan\Type\FileTypeMapper; |
@@ -263,6 +264,8 @@ public function __construct( |
263 | 264 | private readonly bool $polluteScopeWithLoopInitialAssignments, |
264 | 265 | #[AutowiredParameter] |
265 | 266 | private readonly bool $polluteScopeWithAlwaysIterableForeach, |
| 267 | + #[AutowiredParameter(ref: '%featureToggles.narrowForeachBodyNonEmpty%')] |
| 268 | + private readonly bool $narrowForeachBodyNonEmpty, |
266 | 269 | #[AutowiredParameter] |
267 | 270 | private readonly bool $polluteScopeWithBlock, |
268 | 271 | #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] |
@@ -1511,7 +1514,13 @@ public function processStmtNode( |
1511 | 1514 | $originalStorage = $storage; |
1512 | 1515 | $unrolledEndScope = null; |
1513 | 1516 | $unrolledTotalKeys = null; |
1514 | | - $iterateeScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; |
| 1517 | + // The loop body is only entered when the iteratee is non-empty. Under |
| 1518 | + // narrowForeachBodyNonEmpty we narrow it there (list to non-empty-list, |
| 1519 | + // array to non-empty-array) even with polluteScopeWithAlwaysIterableForeach |
| 1520 | + // off; with the toggle off the body scope is unchanged. |
| 1521 | + $iterateeScope = $this->narrowForeachBodyNonEmpty || $this->polluteScopeWithAlwaysIterableForeach |
| 1522 | + ? $scope->filterByTruthyValue($arrayComparisonExpr) |
| 1523 | + : $scope; |
1515 | 1524 | if ($context->isTopLevel()) { |
1516 | 1525 | $storage = $originalStorage->duplicate(); |
1517 | 1526 |
|
@@ -1699,6 +1708,33 @@ public function processStmtNode( |
1699 | 1708 | } |
1700 | 1709 |
|
1701 | 1710 | $isIterableAtLeastOnce = $exprType->isIterableAtLeastOnce(); |
| 1711 | + |
| 1712 | + $iterateeCertainty = $finalScope->hasExpressionType($stmt->expr); |
| 1713 | + if ( |
| 1714 | + $this->narrowForeachBodyNonEmpty |
| 1715 | + && !$this->polluteScopeWithAlwaysIterableForeach |
| 1716 | + && !$iterateeCertainty->no() |
| 1717 | + && !$isIterableAtLeastOnce->yes() |
| 1718 | + ) { |
| 1719 | + // With the flag off the after-loop scope must not assume the loop ran, so |
| 1720 | + // undo the body narrowing: restore the iteratee's possibly-empty-ness |
| 1721 | + // (keeping element types the body refined). Only the non-emptiness the |
| 1722 | + // narrowing added is stripped; an iteratee already non-empty before the |
| 1723 | + // loop keeps it, and a literal like `foreach ([1, 2] as $v)` is skipped. |
| 1724 | + $finalIterateeType = $finalScope->getType($stmt->expr); |
| 1725 | + if ($finalIterateeType->isArray()->yes()) { |
| 1726 | + $finalIterateeNativeType = $finalScope->getNativeType($stmt->expr); |
| 1727 | + $finalScope = $finalScope->specifyExpressionType( |
| 1728 | + $stmt->expr, |
| 1729 | + TypeCombinator::union($finalIterateeType, new ConstantArrayType([], [])), |
| 1730 | + $finalIterateeNativeType->isArray()->yes() |
| 1731 | + ? TypeCombinator::union($finalIterateeNativeType, new ConstantArrayType([], [])) |
| 1732 | + : $finalIterateeNativeType, |
| 1733 | + $iterateeCertainty, |
| 1734 | + ); |
| 1735 | + } |
| 1736 | + } |
| 1737 | + |
1702 | 1738 | if ($isIterableAtLeastOnce->maybe() || $exprType->isIterable()->no()) { |
1703 | 1739 | $finalScope = $finalScope->mergeWith($scope->filterByTruthyValue(new BooleanOr( |
1704 | 1740 | new BinaryOp\Identical( |
|
0 commit comments