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..876baaff49 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/for-loop-expr.php @@ -0,0 +1,52 @@ + $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 list $items + * + * @return list + */ +function getItemsWithForLoopInvertLastCond(array $items): array +{ + for ($i = 0; count($items) > $i; ++$i) { + $items[$i] = 'hello'; + } + + 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; +} 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