From d8ccd7fda85711ee09f5794b5236b77cc24b3b0d Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 1 Oct 2025 17:22:27 +0200 Subject: [PATCH 1/2] Fix "list type lost in for loop" --- src/Analyser/NodeScopeResolver.php | 66 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/for-loop-expr.php | 36 ++++++++++ ...nexistentOffsetInArrayDimFetchRuleTest.php | 11 +--- 3 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/for-loop-expr.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 012baa57ca..ad6d0c9730 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1487,6 +1487,8 @@ private function processStmtNode( $initScope = $condResult->getScope(); $condResultScope = $condResult->getScope(); + // only the last condition expression is relevant whether the loop continues + // see https://www.php.net/manual/en/control-structures.for.php if ($condExpr === $lastCondExpr) { $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResultScope->getType($condExpr) : $condResultScope->getNativeType($condExpr))->toBoolean(); $isIterableAtLeastOnce = $isIterableAtLeastOnce->and($condTruthiness->isTrue()); @@ -1513,6 +1515,7 @@ private function processStmtNode( foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); } + foreach ($stmt->loop as $loopExpr) { $exprResult = $this->processExprNode($stmt, $loopExpr, $bodyScope, static function (): void { }, ExpressionContext::createTopLevel()); @@ -1539,6 +1542,7 @@ private function processStmtNode( if ($lastCondExpr !== null) { $alwaysIterates = $alwaysIterates->and($bodyScope->getType($lastCondExpr)->toBoolean()->isTrue()); $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); + $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope); } $finalScopeResult = $this->processStmtNodes($stmt, $stmt->stmts, $bodyScope, $nodeCallback, $context)->filterOutLoopExitPoints(); @@ -7116,4 +7120,66 @@ public function getFilteringExprForMatchArm(Expr\Match_ $expr, array $conditions ); } + private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, MutatingScope $bodyScope): MutatingScope + { + // infer $items[$i] type from for ($i = 0; $i < count($items); $i++) {...} + + if ( + // $i = 0 + count($stmt->init) === 1 + && $stmt->init[0] instanceof Assign + && $stmt->init[0]->var instanceof Variable + && $stmt->init[0]->expr instanceof Node\Scalar\Int_ + && $stmt->init[0]->expr->value === 0 + // $i++ or ++$i + && count($stmt->loop) === 1 + && ($stmt->loop[0] instanceof Expr\PreInc || $stmt->loop[0] instanceof Expr\PostInc) + && $stmt->loop[0]->var instanceof Variable + ) { + // $i < count($items) + if ( + $lastCondExpr instanceof BinaryOp\Smaller + && $lastCondExpr->left instanceof Variable + && $lastCondExpr->right instanceof FuncCall + && $lastCondExpr->right->name instanceof Name + && $lastCondExpr->right->name->toLowerString() === 'count' + && count($lastCondExpr->right->getArgs()) > 0 + && $lastCondExpr->right->getArgs()[0]->value instanceof Variable + && is_string($stmt->init[0]->var->name) + && $stmt->init[0]->var->name === $stmt->loop[0]->var->name + && $stmt->init[0]->var->name === $lastCondExpr->left->name + ) { + $arrayArg = $lastCondExpr->right->getArgs()[0]->value; + $bodyScope = $bodyScope->assignExpression( + new ArrayDimFetch($lastCondExpr->right->getArgs()[0]->value, $lastCondExpr->left), + $bodyScope->getType($arrayArg)->getIterableValueType(), + $bodyScope->getNativeType($arrayArg)->getIterableValueType(), + ); + } + + // count($items) > $i + if ( + $lastCondExpr instanceof BinaryOp\Greater + && $lastCondExpr->right instanceof Variable + && $lastCondExpr->left instanceof FuncCall + && $lastCondExpr->left->name instanceof Name + && $lastCondExpr->left->name->toLowerString() === 'count' + && count($lastCondExpr->left->getArgs()) > 0 + && $lastCondExpr->left->getArgs()[0]->value instanceof Variable + && is_string($stmt->init[0]->var->name) + && $stmt->init[0]->var->name === $stmt->loop[0]->var->name + && $stmt->init[0]->var->name === $lastCondExpr->right->name + ) { + $arrayArg = $lastCondExpr->left->getArgs()[0]->value; + $bodyScope = $bodyScope->assignExpression( + new ArrayDimFetch($lastCondExpr->left->getArgs()[0]->value, $lastCondExpr->right), + $bodyScope->getType($arrayArg)->getIterableValueType(), + $bodyScope->getNativeType($arrayArg)->getIterableValueType(), + ); + } + } + + return $bodyScope; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/for-loop-expr.php b/tests/PHPStan/Analyser/nsrt/for-loop-expr.php new file mode 100644 index 0000000000..98fafd1203 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/for-loop-expr.php @@ -0,0 +1,36 @@ + $items + * + * @return non-empty-list + */ +function getItemsWithForLoop(array $items): array +{ + for ($i = 0; $i < count($items); $i++) { + $items[$i] = 1; + } + + assertType('non-empty-list', $items); + return $items; +} + +/** + * @param non-empty-list $items + * + * @return non-empty-list + */ +function getItemsWithForLoopInvertLastCond(array $items): array +{ + for ($i = 0; count($items) > $i; ++$i) { + $items[$i] = 'hello'; + } + + assertType('non-empty-list', $items); + return $items; +} diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 0334660b40..c635a33591 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -867,16 +867,7 @@ public function testBug12406b(): void { $this->reportPossiblyNonexistentGeneralArrayOffset = true; - $this->analyse([__DIR__ . '/data/bug-12406b.php'], [ - [ - 'Offset int<0, max> might not exist on non-empty-list.', - 22, - ], - [ - 'Offset int<0, max> might not exist on non-empty-list.', - 23, - ], - ]); + $this->analyse([__DIR__ . '/data/bug-12406b.php'], []); } public function testBug11679(): void From fa6f7b25f2f37746ba3f51ef763b8bd52ca42569 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 3 Oct 2025 18:16:58 +0200 Subject: [PATCH 2/2] test more variants --- tests/PHPStan/Analyser/nsrt/for-loop-expr.php | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/for-loop-expr.php b/tests/PHPStan/Analyser/nsrt/for-loop-expr.php index 98fafd1203..876baaff49 100644 --- a/tests/PHPStan/Analyser/nsrt/for-loop-expr.php +++ b/tests/PHPStan/Analyser/nsrt/for-loop-expr.php @@ -21,9 +21,9 @@ function getItemsWithForLoop(array $items): array } /** - * @param non-empty-list $items + * @param list $items * - * @return non-empty-list + * @return list */ function getItemsWithForLoopInvertLastCond(array $items): array { @@ -31,6 +31,22 @@ function getItemsWithForLoopInvertLastCond(array $items): array $items[$i] = 'hello'; } - assertType('non-empty-list', $items); + assertType('list', $items); + return $items; +} + + +/** + * @param array $items + * + * @return array + */ +function getItemsArray(array $items): array +{ + for ($i = 0; count($items) > $i; ++$i) { + $items[$i] = 'hello'; + } + + assertType('array', $items); return $items; }