diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 9b198d567f5..a1e4919842d 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -511,4 +511,14 @@ public function throwsOnStringCast(): bool return $this->versionId >= 70400; } + public function updatesAutoIncrementKeyForNegativeValues(): bool + { + return $this->versionId >= 80300; + } + + public function updatesAutoIncrementKeyForNegativeValuesOnlyInNonEmptyInitializer(): bool + { + return $this->versionId >= 80000 && $this->versionId < 80300; + } + } diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index aab3b8ad449..9e474f7b413 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Constant; +use PHPStan\Reflection\PhpVersionStaticAccessor; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; @@ -41,6 +42,8 @@ final class ConstantArrayTypeBuilder private bool $oversized = false; + private bool $initializedEmpty = false; + /** * @param list $keyTypes * @param array $valueTypes @@ -72,6 +75,8 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType $startArrayType->isList(), ); + $builder->initializedEmpty = self::wasInitializedEmpty($startArrayType); + if (count($startArrayType->getKeyTypes()) > self::ARRAY_COUNT_LIMIT) { $builder->degradeToGeneralArray(true); } @@ -209,7 +214,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt $this->isList = TrinaryLogic::createNo(); } - if ($offsetValue >= $max) { + if ($this->shouldUpdateAutoIndex($offsetValue, $max)) { /** @var int|float $newAutoIndex */ $newAutoIndex = $offsetValue + 1; if (is_float($newAutoIndex)) { @@ -419,4 +424,43 @@ public function isList(): bool return $this->isList->yes(); } + private function shouldUpdateAutoIndex(int $offsetValue, int $max): bool + { + if ($offsetValue >= $max) { + return true; + } + + if ($offsetValue >= 0 || $max !== 0) { + return false; + } + + $phpVersion = PhpVersionStaticAccessor::getInstance(); + if ($phpVersion->updatesAutoIncrementKeyForNegativeValues()) { + return true; + } + + if ($phpVersion->updatesAutoIncrementKeyForNegativeValuesOnlyInNonEmptyInitializer()) { + return !$this->initializedEmpty; + } + + return false; + } + + private static function wasInitializedEmpty(ConstantArrayType $startArrayType): bool + { + if (count($startArrayType->getKeyTypes()) === 0) { + return true; + } + + if (max($startArrayType->getNextAutoIndexes()) === 0) { + foreach ($startArrayType->getKeyTypes() as $keyType) { + if ($keyType instanceof ConstantIntegerType && $keyType->getValue() < 0) { + return true; + } + } + } + + return false; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-10862-php74.php b/tests/PHPStan/Analyser/nsrt/bug-10862-php74.php new file mode 100644 index 00000000000..192e00b99b1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10862-php74.php @@ -0,0 +1,26 @@ + 1]; + $a[] = 2; + + assertType('array{-4: 1, 0: 2}', $a); +}; diff --git a/tests/PHPStan/Analyser/nsrt/bug-10862-php80.php b/tests/PHPStan/Analyser/nsrt/bug-10862-php80.php new file mode 100644 index 00000000000..6fc33a396d3 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10862-php80.php @@ -0,0 +1,32 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug10862Php80; + +use function PHPStan\Testing\assertType; + +// PHP 8.0+: array literal with negative keys updates auto-index + +function () { + $a = [-4 => 1]; + $a[] = 2; + + assertType('array{-4: 1, -3: 2}', $a); +}; + +function () { + $a = [-10 => 'a', -5 => 'b']; + $a[] = 'c'; + + assertType("array{-10: 'a', -5: 'b', -4: 'c'}", $a); +}; + +// Non-empty string-key array: negative key should update auto-index +function () { + $a = ['foo' => 'bar']; + $a[-5] = 'x'; + $a[] = 'y'; + + assertType("array{foo: 'bar', -5: 'x', -4: 'y'}", $a); +}; diff --git a/tests/PHPStan/Analyser/nsrt/bug-10862-php82.php b/tests/PHPStan/Analyser/nsrt/bug-10862-php82.php new file mode 100644 index 00000000000..b2f41e41502 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10862-php82.php @@ -0,0 +1,43 @@ += 8.3 + +declare(strict_types = 1); + +namespace Bug10862Php83; + +use function PHPStan\Testing\assertType; + +// PHP 8.3+: negative keys always affect auto-index (both imperative and literal) + +// Imperative assignment +function () { + $a = []; + $a[-4] = 1; + $a[] = 2; + + assertType('array{-4: 1, -3: 2}', $a); +}; + +function () { + $a = []; + $a[-1] = 'x'; + $a[] = 'y'; + + assertType("array{-1: 'x', 0: 'y'}", $a); +}; + +function () { + $a = []; + $a[-10] = 'a'; + $a[-5] = 'b'; + $a[] = 'c'; + + assertType("array{-10: 'a', -5: 'b', -4: 'c'}", $a); +}; + +function () { + $a = []; + $a[-3] = 'a'; + $a[5] = 'b'; + $a[] = 'c'; + + assertType("array{-3: 'a', 5: 'b', 6: 'c'}", $a); +}; + +// Array literal +function () { + $a = [-4 => 1]; + $a[] = 2; + + assertType('array{-4: 1, -3: 2}', $a); +}; + +function () { + $a = [-10 => 'a', -5 => 'b']; + $a[] = 'c'; + + assertType("array{-10: 'a', -5: 'b', -4: 'c'}", $a); +};