diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index af1ab31238..008704829b 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -4308,7 +4308,14 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } $scope = $this; - if ($expr instanceof Expr\ArrayDimFetch && $expr->dim !== null) { + if ( + $expr instanceof Expr\ArrayDimFetch + && $expr->dim !== null + && !$expr->dim instanceof Expr\PreInc + && !$expr->dim instanceof Expr\PreDec + && !$expr->dim instanceof Expr\PostDec + && !$expr->dim instanceof Expr\PostInc + ) { $dimType = $scope->getType($expr->dim)->toArrayKey(); if ($dimType->isInteger()->yes() || $dimType->isString()->yes()) { $exprVarType = $scope->getType($expr->var); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d5f87e3f15..6d5d8216a8 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -5706,10 +5706,10 @@ private function processAssignVar( $offsetValueType = $varType; $offsetNativeValueType = $varNativeType; - $valueToWrite = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetTypes, $offsetValueType, $valueToWrite, $scope); + [$valueToWrite, $additionalExpressions] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetTypes, $offsetValueType, $valueToWrite, $scope); if (!$offsetValueType->equals($offsetNativeValueType) || !$valueToWrite->equals($nativeValueToWrite)) { - $nativeValueToWrite = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope); + [$nativeValueToWrite, $additionalNativeExpressions] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope); } else { $rewritten = false; foreach ($offsetTypes as $i => $offsetType) { @@ -5728,7 +5728,7 @@ private function processAssignVar( continue; } - $nativeValueToWrite = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope); + [$nativeValueToWrite] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope); $rewritten = true; break; } @@ -5781,6 +5781,16 @@ private function processAssignVar( } } + foreach ($additionalExpressions as $k => $additionalExpression) { + [$expr, $type] = $additionalExpression; + $nativeType = $type; + if (isset($additionalNativeExpressions[$k])) { + [, $nativeType] = $additionalNativeExpressions[$k]; + } + + $scope = $scope->assignExpression($expr, $type, $nativeType); + } + if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $this->processExprNode( $stmt, @@ -6134,9 +6144,13 @@ private function isImplicitArrayCreation(array $dimFetchStack, Scope $scope): Tr /** * @param list $dimFetchStack * @param list $offsetTypes + * + * @return array{Type, list} */ - private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, array $offsetTypes, Type $offsetValueType, Type $valueToWrite, Scope $scope): Type + private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, array $offsetTypes, Type $offsetValueType, Type $valueToWrite, Scope $scope): array { + $originalValueToWrite = $valueToWrite; + $offsetValueTypeStack = [$offsetValueType]; foreach (array_slice($offsetTypes, 0, -1) as $offsetType) { if ($offsetType === null) { @@ -6204,28 +6218,47 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar continue; } - if ($scope->hasExpressionType($arrayDimFetch)->yes()) { // keep list for $list[$index] assignments + if (!$arrayDimFetch->dim instanceof BinaryOp\Plus) { + continue; + } + + if ( // keep list for $list[$index + 1] assignments + $arrayDimFetch->dim->right instanceof Variable + && $arrayDimFetch->dim->left instanceof Node\Scalar\Int_ + && $arrayDimFetch->dim->left->value === 1 + && $scope->hasExpressionType(new ArrayDimFetch($arrayDimFetch->var, $arrayDimFetch->dim->right))->yes() + ) { $valueToWrite = TypeCombinator::intersect($valueToWrite, new AccessoryArrayListType()); - } elseif ($arrayDimFetch->dim instanceof BinaryOp\Plus) { - if ( // keep list for $list[$index + 1] assignments - $arrayDimFetch->dim->right instanceof Variable - && $arrayDimFetch->dim->left instanceof Node\Scalar\Int_ - && $arrayDimFetch->dim->left->value === 1 - && $scope->hasExpressionType(new ArrayDimFetch($arrayDimFetch->var, $arrayDimFetch->dim->right))->yes() - ) { - $valueToWrite = TypeCombinator::intersect($valueToWrite, new AccessoryArrayListType()); - } elseif ( // keep list for $list[1 + $index] assignments - $arrayDimFetch->dim->left instanceof Variable - && $arrayDimFetch->dim->right instanceof Node\Scalar\Int_ - && $arrayDimFetch->dim->right->value === 1 - && $scope->hasExpressionType(new ArrayDimFetch($arrayDimFetch->var, $arrayDimFetch->dim->left))->yes() - ) { - $valueToWrite = TypeCombinator::intersect($valueToWrite, new AccessoryArrayListType()); - } + } elseif ( // keep list for $list[1 + $index] assignments + $arrayDimFetch->dim->left instanceof Variable + && $arrayDimFetch->dim->right instanceof Node\Scalar\Int_ + && $arrayDimFetch->dim->right->value === 1 + && $scope->hasExpressionType(new ArrayDimFetch($arrayDimFetch->var, $arrayDimFetch->dim->left))->yes() + ) { + $valueToWrite = TypeCombinator::intersect($valueToWrite, new AccessoryArrayListType()); + } + } + + $additionalExpressions = []; + $offsetValueType = $valueToWrite; + $lastDimKey = array_key_last($dimFetchStack); + foreach ($dimFetchStack as $key => $dimFetch) { + if ($dimFetch->dim === null) { + $additionalExpressions = []; + break; } + + if ($key === $lastDimKey) { + $offsetValueType = $originalValueToWrite; + } else { + $offsetType = $scope->getType($dimFetch->dim); + $offsetValueType = $offsetValueType->getOffsetValueType($offsetType); + } + + $additionalExpressions[] = [$dimFetch, $offsetValueType]; } - return $valueToWrite; + return [$valueToWrite, $additionalExpressions]; } private function unwrapAssign(Expr $expr): Expr diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index 587cff9ffa..80984358d1 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -98,6 +98,7 @@ private static function findTestFiles(): iterable yield __DIR__ . '/../Rules/Generics/data/bug-3769.php'; yield __DIR__ . '/../Rules/Generics/data/bug-6301.php'; yield __DIR__ . '/../Rules/PhpDoc/data/bug-4643.php'; + yield __DIR__ . '/../Rules/Arrays/data/bug-13538.php'; if (PHP_VERSION_ID >= 80000) { yield __DIR__ . '/../Rules/Comparison/data/bug-4857.php'; diff --git a/tests/PHPStan/Analyser/nsrt/bug-13039.php b/tests/PHPStan/Analyser/nsrt/bug-13039.php new file mode 100644 index 0000000000..4cf9e3c9de --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13039.php @@ -0,0 +1,21 @@ +> */ + $transactions = []; + + assertType('list>', $transactions); + + foreach (array_keys($transactions) as $k) { + $transactions[$k]['Shares'] = []; + $transactions[$k]['Shares']['Projects'] = []; + $transactions[$k]['Shares']['People'] = []; + } + + assertType('list>', $transactions); +} + diff --git a/tests/PHPStan/Analyser/nsrt/bug-13214.php b/tests/PHPStan/Analyser/nsrt/bug-13214.php new file mode 100644 index 0000000000..3145f1d4b5 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13214.php @@ -0,0 +1,41 @@ + $array + */ + public function sayHello(ArrayAccess $array): void + { + $child = new stdClass(); + + assert($array[1] === null); + + assertType('null', $array[1]); + + $array[1] = $child; + + assertType(stdClass::class, $array[1]); + } + + /** + * @param array $array + */ + public function sayHelloArray(array $array): void + { + $child = new stdClass(); + + assert(($array[1] ?? null) === null); + + assertType('object|null', $array[1]); + + $array[1] = $child; + + assertType(stdClass::class, $array[1]); + } +} diff --git a/tests/PHPStan/Levels/data/arrayAccess.php b/tests/PHPStan/Levels/data/arrayAccess.php index b77266e0f6..b07e6930b7 100644 --- a/tests/PHPStan/Levels/data/arrayAccess.php +++ b/tests/PHPStan/Levels/data/arrayAccess.php @@ -44,3 +44,17 @@ public function doLorem( } } + +/** + * @return mixed[] + */ +function bug12931():array { + /** @var array> $data */ + $data = []; + $data['attr'] = []; + $data['attr']['first'] = 1; + $data['attr']['second'] = 2; + $data['attr']['third'] = 3; + + return $data; +} diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 6c79bcc795..e5d17aba80 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1007,4 +1007,28 @@ public function testBug12926(): void $this->analyse([__DIR__ . '/data/bug-12926.php'], []); } + public function testBug13538(): void + { + $this->reportPossiblyNonexistentConstantArrayOffset = true; + $this->reportPossiblyNonexistentGeneralArrayOffset = true; + + $this->analyse([__DIR__ . '/data/bug-13538.php'], [ + [ + "Offset int might not exist on non-empty-array.", + 13, + ], + [ + "Offset int might not exist on non-empty-array.", + 17, + ], + ]); + } + + public function testBug12805(): void + { + $this->reportPossiblyNonexistentGeneralArrayOffset = true; + + $this->analyse([__DIR__ . '/data/bug-12805.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-12805.php b/tests/PHPStan/Rules/Arrays/data/bug-12805.php new file mode 100644 index 0000000000..f3b49b6153 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-12805.php @@ -0,0 +1,23 @@ + $operations + * @return array + */ +function bug(array $operations): array { + $base = []; + + foreach ($operations as $operationName => $operation) { + if (!isset($base[$operationName])) { + $base[$operationName] = []; + } + if (!isset($base[$operationName]['rtx'])) { + $base[$operationName]['rtx'] = 0; + } + $base[$operationName]['rtx'] += $operation['rtx'] ?? 0; + } + + return $base; +} diff --git a/tests/PHPStan/Rules/Arrays/data/bug-13538.php b/tests/PHPStan/Rules/Arrays/data/bug-13538.php new file mode 100644 index 0000000000..d1eec2b8d1 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-13538.php @@ -0,0 +1,61 @@ + $arr */ +function doFoo(array $arr, int $i, int $i2): void +{ + $logs = []; + $logs[$i] = ''; + echo $logs[$i2]; + + assertType("non-empty-array", $logs); + assertType("''", $logs[$i]); + assertType("''", $logs[$i2]); // could be mixed + + foreach ($arr as $value) { + echo $logs[$i]; + + assertType("non-empty-array", $logs); + assertType("''", $logs[$i]); + } +} + +/** @param list $arr */ +function doFooBar(array $arr): void +{ + if (!defined('LOG_DIR')) { + throw new LogicException(); + } + + $logs = []; + $logs[LOG_DIR] = ''; + + assertType("non-empty-array<''>", $logs); + assertType("''", $logs[LOG_DIR]); + + foreach ($arr as $value) { + echo $logs[LOG_DIR]; + + assertType("non-empty-array<''>", $logs); + assertType("''", $logs[LOG_DIR]); + } +} + +function doBar(array $arr, int $i, string $s): void +{ + $logs = []; + $logs[$i][$s] = ''; + assertType("non-empty-array>", $logs); + assertType("non-empty-array", $logs[$i]); + assertType("''", $logs[$i][$s]); + foreach ($arr as $value) { + assertType("non-empty-array>", $logs); + assertType("non-empty-array", $logs[$i]); + assertType("''", $logs[$i][$s]); + echo $logs[$i][$s]; + } +} diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 087098ec01..c795706ab9 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1121,4 +1121,10 @@ public function testBug7773(): void $this->analyse([__DIR__ . '/data/bug-7773.php'], []); } + public function testPr4375(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/pr-4375.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/pr-4375.php b/tests/PHPStan/Rules/Comparison/data/pr-4375.php new file mode 100644 index 0000000000..26df1dbfa4 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/pr-4375.php @@ -0,0 +1,27 @@ +get() as $collected) { + foreach ($collected as [$className, $methodName, $classDisplayName]) { + $className = strtolower($className); + + if (!array_key_exists($className, $methods)) { + $methods[$className] = []; + } + $methods[$className][strtolower($methodName)] = $classDisplayName . '::' . $methodName; + } + } + + return []; + } + + private function get(): array { + return []; + } +} diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 6d813ea0bf..dd51c3df3a 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1265,4 +1265,15 @@ public function testBug7225(): void $this->analyse([__DIR__ . '/data/bug-7225.php'], []); } + public function testDeepDimFetch(): void + { + $this->analyse([__DIR__ . '/data/deep-dim-fetch.php'], []); + } + + #[RequiresPhp('>= 8.0')] + public function testBug9494(): void + { + $this->analyse([__DIR__ . '/data/bug-9494.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-9494.php b/tests/PHPStan/Rules/Methods/data/bug-9494.php new file mode 100644 index 0000000000..3e9146ab6b --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-9494.php @@ -0,0 +1,53 @@ + 0-indexed memoization */ + protected $mem = []; + + public function __construct(public int $limit) { + $this->mem = array_fill(2, $limit, null); + } + + /** + * Calculate fib, 1-indexed + */ + public function fib(int $n): int + { + if ($n < 1 || $n > $this->limit) { + throw new \RangeException(); + } + + if ($n == 1 || $n == 2) { + return 1; + } + + if (is_null($this->mem[$n - 1])) { + $this->mem[$n - 1] = $this->fib($n - 1) + $this->fib($n - 2); + } + + return $this->mem[$n - 1]; // Is always an int at this stage + } + + /** + * Calculate fib, 0-indexed + */ + public function fib0(int $n0): int + { + if ($n0 < 0 || $n0 >= $this->limit) { + throw new \RangeException(); + } + + if ($n0 == 0 || $n0 == 1) { + return 1; + } + + if (is_null($this->mem[$n0])) { + $this->mem[$n0] = $this->fib0($n0 - 1) + $this->fib0($n0 - 2); + } + + return $this->mem[$n0]; + } +} diff --git a/tests/PHPStan/Rules/Methods/data/deep-dim-fetch.php b/tests/PHPStan/Rules/Methods/data/deep-dim-fetch.php new file mode 100644 index 0000000000..c08bb442e5 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/deep-dim-fetch.php @@ -0,0 +1,51 @@ +getNameScopeKey($fileName, $className, $traitName, $functionName); + if (!isset($this->inProcess[$fileName][$nameScopeKey])) { // wrong $fileName due to traits + throw new \RuntimeException(); + } + + if ($this->inProcess[$fileName][$nameScopeKey] === true) { // PHPDoc has cyclic dependency + throw new \RuntimeException(); + } + + if (is_callable($this->inProcess[$fileName][$nameScopeKey])) { + $resolveCallback = $this->inProcess[$fileName][$nameScopeKey]; + $this->inProcess[$fileName][$nameScopeKey] = true; + $this->inProcess[$fileName][$nameScopeKey] = $resolveCallback(); + } + + return $this->inProcess[$fileName][$nameScopeKey]; + } + + private function getNameScopeKey( + ?string $file, + ?string $class, + ?string $trait, + ?string $function, + ): string + { + return ''; + } + +} diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index 8ea1fb0443..da52dc925f 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -492,4 +492,16 @@ public function testIssetAfterRememberedConstructor(): void ]); } + public function testPr4374(): void + { + $this->treatPhpDocTypesAsCertain = true; + + $this->analyse([__DIR__ . '/data/pr-4374.php'], [ + [ + 'Offset string on array in isset() always exists and is not nullable.', + 23, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/pr-4374.php b/tests/PHPStan/Rules/Variables/data/pr-4374.php new file mode 100644 index 0000000000..8a19e13efd --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/pr-4374.php @@ -0,0 +1,34 @@ +methods[$cacheKey][$methodName])) { + $method = $this->findClassReflectionWithMethod(); + if ($method === null) { + return false; + } + $this->methods[$cacheKey][$methodName] = $method; + } + + return isset($this->methods[$cacheKey][$methodName]); + } + + private function findClassReflectionWithMethod( + ): ?Foo + { + if (rand(0,1)) { + return new Foo(); + } + return null; + } +}