diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index b43f0298cc..1d650525df 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -5919,9 +5919,18 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } $offsetValueType = TypeCombinator::intersect($offsetValueType, TypeCombinator::union(...$types)); } - $valueToWrite = $offsetValueType->setOffsetValueType($offsetType, $valueToWrite, $i === 0); $arrayDimFetch = $dimFetchStack[$i] ?? null; + if ( + $offsetType !== null + && $arrayDimFetch !== null + && $scope->hasExpressionType($arrayDimFetch)->yes() + ) { + $valueToWrite = $offsetValueType->setExistingOffsetValueType($offsetType, $valueToWrite); + } else { + $valueToWrite = $offsetValueType->setOffsetValueType($offsetType, $valueToWrite, $i === 0); + } + if ($arrayDimFetch === null || !$offsetValueType->isList()->yes()) { continue; } diff --git a/src/Type/Accessory/AccessoryArrayListType.php b/src/Type/Accessory/AccessoryArrayListType.php index 5f60fe8eb7..eb08e0c8c9 100644 --- a/src/Type/Accessory/AccessoryArrayListType.php +++ b/src/Type/Accessory/AccessoryArrayListType.php @@ -156,11 +156,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type { - if ((new ConstantIntegerType(0))->isSuperTypeOf($offsetType)->yes()) { - return $this; - } - - return new ErrorType(); + return $this; } public function unsetOffset(Type $offsetType): Type diff --git a/src/Type/Accessory/HasOffsetValueType.php b/src/Type/Accessory/HasOffsetValueType.php index ec6e822a31..4e476d7e2e 100644 --- a/src/Type/Accessory/HasOffsetValueType.php +++ b/src/Type/Accessory/HasOffsetValueType.php @@ -184,6 +184,10 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type { + if (!$offsetType->equals($this->offsetType)) { + return $this; + } + return new self($this->offsetType, $valueType); } diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index e68a6a61d3..db2efcf938 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -356,9 +356,23 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type { - return new self( - $this->keyType, - TypeCombinator::union($this->itemType, $valueType), + if ($offsetType instanceof ConstantStringType || $offsetType instanceof ConstantIntegerType) { + return TypeCombinator::intersect( + new self( + TypeCombinator::union($this->keyType, $offsetType), + TypeCombinator::union($this->itemType, $valueType), + ), + new HasOffsetValueType($offsetType, $valueType), + new NonEmptyArrayType(), + ); + } + + return TypeCombinator::intersect( + new self( + $this->keyType, + TypeCombinator::union($this->itemType, $valueType), + ), + new NonEmptyArrayType(), ); } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 8e76f0d08f..39e21e9f83 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -686,15 +686,8 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type { - $offsetType = $offsetType->toArrayKey(); $builder = ConstantArrayTypeBuilder::createFromConstantArray($this); - foreach ($this->keyTypes as $keyType) { - if ($offsetType->isSuperTypeOf($keyType)->no()) { - continue; - } - - $builder->setOffsetValueType($keyType, $valueType); - } + $builder->setOffsetValueType($offsetType, $valueType); return $builder->getArray(); } diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 149536a573..bc2c1916b8 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -826,6 +826,10 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni } } + if ($this->isList()->yes() && $this->getIterableValueType()->isArray()->yes()) { + $result = TypeCombinator::intersect($result, new AccessoryArrayListType()); + } + return $result; } diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index a2e9ef0619..a64e983c6d 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -211,6 +211,7 @@ private static function findTestFiles(): iterable yield __DIR__ . '/../Rules/Arrays/data/bug-11679.php'; yield __DIR__ . '/../Rules/Methods/data/bug-4801.php'; yield __DIR__ . '/../Rules/Arrays/data/narrow-superglobal.php'; + yield __DIR__ . '/../Rules/Methods/data/bug-12927.php'; } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-12274.php b/tests/PHPStan/Analyser/nsrt/bug-12274.php index 437dc09ae3..f0536a0c15 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12274.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12274.php @@ -56,8 +56,8 @@ function testKeepNestedListAfterIssetIndex(array $nestedList, int $i, int $j): v assertType('list>', $nestedList); assertType('list', $nestedList[$i]); $nestedList[$i][$j] = 21; - assertType('non-empty-list>', $nestedList); - assertType('non-empty-list', $nestedList[$i]); + assertType('non-empty-list>', $nestedList); + assertType('list', $nestedList[$i]); } assertType('list>', $nestedList); } diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 548589722e..26b4a471b3 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1232,6 +1232,11 @@ public function testBug1O580(): void ]); } + public function testBug12927(): void + { + $this->analyse([__DIR__ . '/data/bug-12927.php'], []); + } + public function testBug4443(): void { if (PHP_VERSION_ID < 80000) { diff --git a/tests/PHPStan/Rules/Methods/data/bug-12927.php b/tests/PHPStan/Rules/Methods/data/bug-12927.php new file mode 100644 index 0000000000..0331446aec --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-12927.php @@ -0,0 +1,63 @@ + $list + * @return list> + */ + public function sayHello(array $list): array + { + foreach($list as $k => $v) { + unset($list[$k]['abc']); + assertType('non-empty-list', $list); + assertType('array{}|array{abc: string}', $list[$k]); + } + return $list; + } + + /** + * @param list> $list + */ + public function sayFoo(array $list): void + { + foreach($list as $k => $v) { + unset($list[$k]['abc']); + assertType('non-empty-list>', $list); + assertType('array', $list[$k]); + } + assertType('list>', $list); + } + + /** + * @param list> $list + */ + public function sayFoo2(array $list): void + { + foreach($list as $k => $v) { + $list[$k]['abc'] = 'world'; + assertType("non-empty-list&hasOffsetValue('abc', 'world')>", $list); + assertType("non-empty-array&hasOffsetValue('abc', 'world')", $list[$k]); + } + assertType("list&hasOffsetValue('abc', 'world')>", $list); + } + + /** + * @param list> $list + */ + public function sayFooBar(array $list): void + { + foreach($list as $k => $v) { + if (rand(0,1)) { + unset($list[$k]); + } + assertType('array, array>', $list); + assertType('array', $list[$k]); + } + assertType('array', $list[$k]); + } +} diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index 8d050e7636..90c1bdf9ae 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -779,4 +779,20 @@ public function testPropertyHooks(): void ]); } + public function testBug11171(): void + { + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-11171.php'], []); + } + + public function testBug8282(): void + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('Test requires PHP 8.0.'); + } + + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-8282.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-11171.php b/tests/PHPStan/Rules/Properties/data/bug-11171.php new file mode 100644 index 0000000000..688e1c501c --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-11171.php @@ -0,0 +1,41 @@ + + */ + public array $innerTypeExpressions = []; + + /** + * @param \Closure(self): void $callback + */ + public function walkTypes(\Closure $callback): void + { + $startIndexOffset = 0; + + foreach ($this->innerTypeExpressions as $k => ['start_index' => $startIndexOrig, + 'expression' => $inner,]) { + $this->innerTypeExpressions[$k]['start_index'] += $startIndexOffset; + + $innerLengthOrig = \strlen($inner->value); + + $inner->walkTypes($callback); + + $this->value = substr_replace( + $this->value, + $inner->value, + $startIndexOrig + $startIndexOffset, + $innerLengthOrig + ); + + $startIndexOffset += \strlen($inner->value) - $innerLengthOrig; + } + + $callback($this); + } +} diff --git a/tests/PHPStan/Rules/Properties/data/bug-8282.php b/tests/PHPStan/Rules/Properties/data/bug-8282.php new file mode 100644 index 0000000000..faaa9a103a --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-8282.php @@ -0,0 +1,31 @@ += 8.0 + +namespace Bug8282; + +/** + * @phpstan-type record array{id: positive-int, name: string} + */ +class Collection +{ + /** @param list $list */ + public function __construct( + public array $list + ) + { + } + + public function updateName(int $index, string $name): void + { + assert(isset($this->list[$index])); + $this->list[$index]['name'] = $name; + } + + public function updateNameById(int $id, string $name): void + { + foreach ($this->list as $index => $entry) { + if ($entry['id'] === $id) { + $this->list[$index]['name'] = $name; + } + } + } +} diff --git a/tests/PHPStan/Rules/Variables/ParameterOutAssignedTypeRuleTest.php b/tests/PHPStan/Rules/Variables/ParameterOutAssignedTypeRuleTest.php index f8268f8fcd..3c6585eade 100644 --- a/tests/PHPStan/Rules/Variables/ParameterOutAssignedTypeRuleTest.php +++ b/tests/PHPStan/Rules/Variables/ParameterOutAssignedTypeRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\Rule as TRule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -43,7 +44,7 @@ public function testRule(): void 47, ], [ - 'Parameter &$p @param-out type of method ParameterOutAssignedType\Foo::doBaz3() expects list>, array, array, int>> given.', + 'Parameter &$p @param-out type of method ParameterOutAssignedType\Foo::doBaz3() expects list>, list, int>> given.', 56, ], [ @@ -64,4 +65,12 @@ public function testBenevolentArrayKey(): void $this->analyse([__DIR__ . '/data/benevolent-array-key.php'], []); } + public function testBug12754(): void + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('PHP 8.0+ is required for this test.'); + } + $this->analyse([__DIR__ . '/data/bug-12754.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/ParameterOutExecutionEndTypeRuleTest.php b/tests/PHPStan/Rules/Variables/ParameterOutExecutionEndTypeRuleTest.php index 5929aad03a..8bee23c88b 100644 --- a/tests/PHPStan/Rules/Variables/ParameterOutExecutionEndTypeRuleTest.php +++ b/tests/PHPStan/Rules/Variables/ParameterOutExecutionEndTypeRuleTest.php @@ -58,4 +58,9 @@ public function testBug11363(): void $this->analyse([__DIR__ . '/data/bug-11363.php'], []); } + public function testBug12330(): void + { + $this->analyse([__DIR__ . '/data/bug-12330.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-12330.php b/tests/PHPStan/Rules/Variables/data/bug-12330.php new file mode 100644 index 0000000000..d2e2f08a38 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-12330.php @@ -0,0 +1,25 @@ +>} $options + * @param-out array{items: list>} $options + */ +function alterItems(array &$options): void +{ + foreach ($options['items'] as $i => $item) { + $options['items'][$i]['options']['title'] = $item['name']; + } +} + +/** + * @param array{items: array>} $options + * @param-out array{items: array>} $options + */ +function alterItems2(array &$options): void +{ + foreach ($options['items'] as $i => $item) { + $options['items'][$i]['options']['title'] = $item['name']; + } +} diff --git a/tests/PHPStan/Rules/Variables/data/bug-12754.php b/tests/PHPStan/Rules/Variables/data/bug-12754.php new file mode 100644 index 0000000000..e8269ff4d0 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-12754.php @@ -0,0 +1,26 @@ + $list + * @return void + */ + public function modify(array &$list): void + { + foreach ($list as $int => $array) { + $list[$int][1] = $this->apply($array[1]); + } + } + + /** + * @param string $value + * @return string + */ + public function apply(string $value): mixed + { + return $value; + } +} diff --git a/tests/PHPStan/Rules/Variables/data/bug-8113.php b/tests/PHPStan/Rules/Variables/data/bug-8113.php index 27ebe729ae..49bbbc89bb 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-8113.php +++ b/tests/PHPStan/Rules/Variables/data/bug-8113.php @@ -34,7 +34,7 @@ function () { ]; assertType("non-empty-array>&hasOffsetValue('Review', array{id: null, text: null, answer: null})&hasOffsetValue('SurveyInvitation', non-empty-array&hasOffsetValue('review', null))", $review); unset($review['SurveyInvitation']['review']); - assertType("non-empty-array>&hasOffsetValue('Review', array)&hasOffsetValue('SurveyInvitation', array)", $review); + assertType("non-empty-array>&hasOffsetValue('Review', array{id: null, text: null, answer: null})&hasOffsetValue('SurveyInvitation', array)", $review); } assertType('array>', $review); if (array_key_exists('User', $review['Review'])) { @@ -42,7 +42,7 @@ function () { $review['User'] = $review['Review']['User']; assertType("non-empty-array&hasOffsetValue('Review', non-empty-array&hasOffset('User'))&hasOffsetValue('User', mixed)", $review); unset($review['Review']['User']); - assertType("non-empty-array&hasOffsetValue('Review', array)&hasOffsetValue('User', array)", $review); + assertType("non-empty-array&hasOffsetValue('Review', array)&hasOffsetValue('User', mixed)", $review); } assertType("non-empty-array&hasOffsetValue('Review', array)", $review); };