diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 8ff656bb57..82697affa6 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -181,6 +181,7 @@ use PHPStan\Type\Generic\TemplateTypeVarianceMap; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; +use PHPStan\Type\IterableType; use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\NullType; @@ -1250,6 +1251,19 @@ private function processStmtNode( $exprType = $scope->getType($stmt->expr); $isIterableAtLeastOnce = $exprType->isIterableAtLeastOnce(); if ($exprType->isIterable()->no() || $isIterableAtLeastOnce->maybe()) { + $foreachType = $this->getForeachIterateeType(); + if ( + !$foreachType->isSuperTypeOf($exprType)->yes() + && $finalScope->getType($stmt->expr)->equals($foreachType) + ) { + // restore iteratee type, in case the type was narrowed while entering the foreach + $finalScope = $finalScope->assignExpression( + $stmt->expr, + $exprType, + $scope->getNativeType($stmt->expr), + ); + } + $finalScope = $finalScope->mergeWith($scope->filterByTruthyValue(new BooleanOr( new BinaryOp\Identical( $stmt->expr, @@ -6307,11 +6321,32 @@ private function processVarAnnotation(MutatingScope $scope, array $variableNames return $scope; } + private function getForeachIterateeType(): Type + { + return new IterableType(new MixedType(), new MixedType()); + } + private function enterForeach(MutatingScope $scope, MutatingScope $originalScope, Foreach_ $stmt): MutatingScope { if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { $scope = $this->processVarAnnotation($scope, [$stmt->expr->name], $stmt); } + + // narrow the iteratee type to those supported by foreach + $foreachType = $this->getForeachIterateeType(); + $scope = $scope->specifyExpressionType( + $stmt->expr, + TypeCombinator::intersect( + $scope->getType($stmt->expr), + $foreachType, + ), + TypeCombinator::intersect( + $scope->getNativeType($stmt->expr), + $foreachType, + ), + TrinaryLogic::createYes(), + ); + $iterateeType = $originalScope->getType($stmt->expr); if ( ($stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name)) diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 86609521db..87e6b07785 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -1131,7 +1131,7 @@ private function getComposerLocks(): array } /** - * @return array + * @return array> */ private function getComposerInstalled(): array { @@ -1149,6 +1149,10 @@ private function getComposerInstalled(): array } $installed = require $filePath; + if (!is_array($installed)) { + throw new ShouldNotHappenException(); + } + $rootName = $installed['root']['name']; unset($installed['root']); unset($installed['versions'][$rootName]); diff --git a/src/Type/MixedType.php b/src/Type/MixedType.php index 1245743947..ce485141a6 100644 --- a/src/Type/MixedType.php +++ b/src/Type/MixedType.php @@ -169,7 +169,14 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type { - return new self($this->isExplicitMixed); + $types = [ + new ArrayType(new MixedType(), new MixedType()), + new ObjectType(ArrayAccess::class), + ]; + if (!$offsetType->isInteger()->no()) { + $types[] = new StringType(); + } + return TypeCombinator::union(...$types); } public function unsetOffset(Type $offsetType): Type diff --git a/tests/PHPStan/Analyser/nsrt/bug-13270a.php b/tests/PHPStan/Analyser/nsrt/bug-13270a.php new file mode 100644 index 0000000000..3057ba009b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13270a.php @@ -0,0 +1,54 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug13270a; + +use function PHPStan\Testing\assertType; + +final class HelloWorld +{ + /** + * @param array $data + */ + public function test(array $data): void + { + foreach($data as $k => $v) { + assertType('non-empty-array', $data); + $data[$k]['a'] = true; + assertType("non-empty-array<(non-empty-array&hasOffsetValue('a', true))|(ArrayAccess&hasOffsetValue('a', true))>", $data); + foreach($data[$k] as $val) { + } + } + } + + public function doFoo( + mixed $mixed, + mixed $mixed2, + mixed $mixed3, + mixed $mixed4, + int $i, + int $i2, + string|int $stringOrInt + ): void + { + $mixed[$i]['a'] = true; + assertType('mixed', $mixed); + + $mixed2[$stringOrInt]['a'] = true; + assertType('mixed', $mixed2); + + $mixed3[$i][$stringOrInt] = true; + assertType('mixed', $mixed3); + + $mixed4['a'][$stringOrInt] = true; + assertType('mixed', $mixed4); + + $null = null; + $null[$i]['a'] = true; + assertType('non-empty-array', $null); + + $i2['a'] = true; + assertType('*ERROR*', $i2); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13270b.php b/tests/PHPStan/Analyser/nsrt/bug-13270b.php new file mode 100644 index 0000000000..aeae142564 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13270b.php @@ -0,0 +1,27 @@ += 8.0 + +namespace Bug13312; + +use function PHPStan\Testing\assertType; + +function fooArr(array $arr): void { + assertType('array', $arr); + foreach ($arr as $v) { + assertType('non-empty-array', $arr); + } + assertType('array', $arr); + + for ($i = 0; $i < count($arr); ++$i) { + assertType('non-empty-array', $arr); + } + assertType('array', $arr); +} + +/** @param list $arr */ +function foo(array $arr): void { + assertType('list', $arr); + foreach ($arr as $v) { + assertType('non-empty-list', $arr); + } + assertType('list', $arr); + + for ($i = 0; $i < count($arr); ++$i) { + assertType('non-empty-list', $arr); + } + assertType('list', $arr); +} + + +function fooBar(mixed $mixed): void { + assertType('mixed', $mixed); + foreach ($mixed as $v) { + assertType('iterable', $mixed); // could be non-empty-array|Traversable + } + assertType('mixed', $mixed); + + foreach ($mixed as $v) {} + + assertType('mixed', $mixed); +} diff --git a/tests/PHPStan/Analyser/nsrt/composer-array-bug.php b/tests/PHPStan/Analyser/nsrt/composer-array-bug.php index 354577f098..534afc600d 100644 --- a/tests/PHPStan/Analyser/nsrt/composer-array-bug.php +++ b/tests/PHPStan/Analyser/nsrt/composer-array-bug.php @@ -16,15 +16,18 @@ class Foo public function doFoo(): void { if (!empty($this->config['authors'])) { + assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']); foreach ($this->config['authors'] as $key => $author) { + assertType("iterable", $this->config['authors']); + if (!is_array($author)) { $this->errors[] = 'authors.'.$key.' : should be an array, '.gettype($author).' given'; - assertType("mixed", $this->config['authors']); + assertType("iterable", $this->config['authors']); unset($this->config['authors'][$key]); - assertType("mixed", $this->config['authors']); + assertType("iterable", $this->config['authors']); continue; } - assertType("mixed", $this->config['authors']); + assertType("iterable", $this->config['authors']); foreach (['homepage', 'email', 'name', 'role'] as $authorData) { if (isset($author[$authorData]) && !is_string($author[$authorData])) { $this->errors[] = 'authors.'.$key.'.'.$authorData.' : invalid value, must be a string'; @@ -32,9 +35,9 @@ public function doFoo(): void } } if (isset($author['homepage'])) { - assertType("mixed", $this->config['authors']); + assertType("iterable", $this->config['authors']); unset($this->config['authors'][$key]['homepage']); - assertType("mixed", $this->config['authors']); + assertType("iterable", $this->config['authors']); } if (isset($author['email']) && !filter_var($author['email'], FILTER_VALIDATE_EMAIL)) { unset($this->config['authors'][$key]['email']); @@ -44,8 +47,8 @@ public function doFoo(): void } } - assertType("non-empty-array&hasOffsetValue('authors', mixed)", $this->config); - assertType("mixed", $this->config['authors']); + assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config); + assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']); if (empty($this->config['authors'])) { unset($this->config['authors']); @@ -54,7 +57,7 @@ public function doFoo(): void assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config); } - assertType('array', $this->config); + assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config); } } diff --git a/tests/PHPStan/Analyser/nsrt/composer-non-empty-array-after-unset.php b/tests/PHPStan/Analyser/nsrt/composer-non-empty-array-after-unset.php index 0cec47c79a..1d4c09dca7 100644 --- a/tests/PHPStan/Analyser/nsrt/composer-non-empty-array-after-unset.php +++ b/tests/PHPStan/Analyser/nsrt/composer-non-empty-array-after-unset.php @@ -15,10 +15,10 @@ public function doFoo() if (!empty($this->config['authors'])) { assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']); foreach ($this->config['authors'] as $key => $author) { - assertType("mixed", $this->config['authors']); + assertType("iterable", $this->config['authors']); if (!is_array($author)) { unset($this->config['authors'][$key]); - assertType("mixed", $this->config['authors']); + assertType("iterable", $this->config['authors']); continue; } foreach (['homepage', 'email', 'name', 'role'] as $authorData) { @@ -33,13 +33,13 @@ public function doFoo() unset($this->config['authors'][$key]['email']); } if (empty($this->config['authors'][$key])) { - assertType("mixed", $this->config['authors']); + assertType("iterable", $this->config['authors']); unset($this->config['authors'][$key]); - assertType("mixed", $this->config['authors']); + assertType("iterable", $this->config['authors']); } - assertType("mixed", $this->config['authors']); + assertType("iterable", $this->config['authors']); } - assertType("mixed", $this->config['authors']); + assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']); } } diff --git a/tests/PHPStan/Rules/Arrays/IterableInForeachRuleTest.php b/tests/PHPStan/Rules/Arrays/IterableInForeachRuleTest.php index f392ac3202..e00b70e84e 100644 --- a/tests/PHPStan/Rules/Arrays/IterableInForeachRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/IterableInForeachRuleTest.php @@ -139,4 +139,10 @@ public function testMixed(bool $checkExplicitMixed, bool $checkImplicitMixed, ar $this->analyse([__DIR__ . '/data/foreach-mixed.php'], $errors); } + #[RequiresPhp('>= 8.0')] + public function testBug13312(): void + { + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13312.php'], []); + } + }