diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 3a65de0c79..894fbfc4f1 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -41,6 +41,8 @@ final class ConstantArrayTypeBuilder private bool $oversized = false; + private TrinaryLogic $isNonEmpty; + /** * @param list $keyTypes * @param array $valueTypes @@ -55,6 +57,7 @@ private function __construct( private TrinaryLogic $isList, ) { + $this->isNonEmpty = TrinaryLogic::createNo(); } public static function createEmpty(): self @@ -71,6 +74,7 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType $startArrayType->getOptionalKeys(), $startArrayType->isList(), ); + $builder->isNonEmpty = $startArrayType->isIterableAtLeastOnce(); if (count($startArrayType->getKeyTypes()) > self::ARRAY_COUNT_LIMIT) { $builder->degradeToGeneralArray(true); @@ -89,6 +93,10 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt return; } + if (!$optional) { + $this->isNonEmpty = TrinaryLogic::createYes(); + } + if (!$this->degradeToGeneralArray) { if ( $valueType instanceof ClosureType @@ -388,7 +396,11 @@ public function getArray(): Type if (!$this->degradeToGeneralArray) { /** @var list $keyTypes */ $keyTypes = $this->keyTypes; - return new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + $array = new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + if ($this->isNonEmpty->yes() && !$array->isIterableAtLeastOnce()->yes()) { + return TypeCombinator::intersect($array, new NonEmptyArrayType()); + } + return $array; } if ($this->degradeClosures === true) { @@ -410,7 +422,7 @@ public function getArray(): Type ); $types = []; - if (count($this->optionalKeys) < $keyTypesCount) { + if ($this->isNonEmpty->yes() || count($this->optionalKeys) < $keyTypesCount) { $types[] = new NonEmptyArrayType(); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14551.php b/tests/PHPStan/Analyser/nsrt/bug-14551.php index 9b31fd2729..0f70cefc01 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14551.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14551.php @@ -15,7 +15,7 @@ function foreachTwoKeys(array $keys): void $result[$k]['y'] = 2; } - assertType("array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}}", $result); + assertType("non-empty-array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}}", $result); } /** @@ -30,7 +30,7 @@ function foreachThreeKeys(array $keys): void $result[$k]['z'] = 3; } - assertType("array{a?: array{x: 1, y: 2, z: 3}, b?: array{x: 1, y: 2, z: 3}}", $result); + assertType("non-empty-array{a?: array{x: 1, y: 2, z: 3}, b?: array{x: 1, y: 2, z: 3}}", $result); } /** @@ -43,7 +43,7 @@ function withoutForeach(string $k): void $result[$k]['x'] = 1; $result[$k]['y'] = 2; - assertType("array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}}", $result); + assertType("non-empty-array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}}", $result); } /** @@ -56,7 +56,7 @@ function integerKeys(int $k): void $result[$k]['x'] = 1; $result[$k]['y'] = 2; - assertType("array{0?: array{x: 1, y: 2}, 1?: array{x: 1, y: 2}}", $result); + assertType("non-empty-array{0?: array{x: 1, y: 2}, 1?: array{x: 1, y: 2}}", $result); } /** @@ -69,7 +69,7 @@ function threeWayUnion(string $k): void $result[$k]['x'] = 1; $result[$k]['y'] = 2; - assertType("array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}, c?: array{x: 1, y: 2}}", $result); + assertType("non-empty-array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}, c?: array{x: 1, y: 2}}", $result); } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-14552.php b/tests/PHPStan/Analyser/nsrt/bug-14552.php new file mode 100644 index 0000000000..6f9d06c666 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14552.php @@ -0,0 +1,74 @@ + $keys + */ +function nonEmptyListForeach(array $keys): void +{ + $out = []; + foreach ($keys as $k) { + $out[$k] = 1; + } + assertType("non-empty-array{a?: 1, b?: 1}", $out); +} + +/** + * @param list<'a'|'b'> $keys + */ +function possiblyEmptyListForeach(array $keys): void +{ + $out = []; + foreach ($keys as $k) { + $out[$k] = 1; + } + assertType("array{}|array{a?: 1, b?: 1}", $out); +} + +/** + * @param non-empty-list<'x'|'y'|'z'> $keys + */ +function nonEmptyListThreeKeys(array $keys): void +{ + $out = []; + foreach ($keys as $k) { + $out[$k] = true; + } + assertType("non-empty-array{x?: true, y?: true, z?: true}", $out); +} + +/** + * Direct assignment (non-foreach) with union key on empty array. + * @param 'a'|'b' $key + */ +function directAssignment(string $key): void +{ + $arr = []; + $arr[$key] = 1; + assertType("non-empty-array{a?: 1, b?: 1}", $arr); +} + +/** + * Direct assignment with integer union key on empty array. + * @param 0|1|2 $key + */ +function directAssignmentIntKey(int $key): void +{ + $arr = []; + $arr[$key] = 'val'; + assertType("non-empty-array{0?: 'val', 1?: 'val', 2?: 'val'}", $arr); +} + +/** + * Setting union key on already non-empty array should stay non-empty. + * @param 'x'|'y' $key + */ +function setOnNonEmptyArray(string $key): void +{ + $arr = ['existing' => 0]; + $arr[$key] = 1; + assertType("array{existing: 0, x?: 1, y?: 1}", $arr); +} diff --git a/tests/PHPStan/Analyser/nsrt/set-union-offset-preserve-constant-array.php b/tests/PHPStan/Analyser/nsrt/set-union-offset-preserve-constant-array.php index fa8a88e851..18f9b71e6b 100644 --- a/tests/PHPStan/Analyser/nsrt/set-union-offset-preserve-constant-array.php +++ b/tests/PHPStan/Analyser/nsrt/set-union-offset-preserve-constant-array.php @@ -13,7 +13,7 @@ public function doFoo(): void $k = rand(0, 1) ? 1 : 2; $a[$k] = true; - assertType('array{1?: true, 2?: true}', $a); + assertType('non-empty-array{1?: true, 2?: true}', $a); } } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php index 8a2783a46a..12fb1c2b8f 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php @@ -267,4 +267,50 @@ public function testSetOffsetValueTypeOnConstantArrayWithEmptyNextAutoIndexesRet $this->assertInstanceOf(ErrorType::class, $result); } + public function testNonOptionalUnionOffsetOnEmptyArrayIsNonEmpty(): void + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + $aOrB = TypeCombinator::union( + new ConstantStringType('a'), + new ConstantStringType('b'), + ); + $builder->setOffsetValueType($aOrB, new ConstantIntegerType(1)); + + $array = $builder->getArray(); + $this->assertSame('non-empty-array{a?: 1, b?: 1}', $array->describe(VerbosityLevel::precise())); + } + + public function testOptionalSingleOffsetOnEmptyArrayIsPossiblyEmpty(): void + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->setOffsetValueType(new ConstantStringType('a'), new ConstantIntegerType(1), true); + + $array = $builder->getArray(); + $this->assertSame('array{a?: 1}', $array->describe(VerbosityLevel::precise())); + } + + public function testOptionalUnionOffsetOnEmptyArrayIsPossiblyEmpty(): void + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + $aOrB = TypeCombinator::union( + new ConstantStringType('a'), + new ConstantStringType('b'), + ); + $builder->setOffsetValueType($aOrB, new ConstantIntegerType(1), true); + + $array = $builder->getArray(); + $this->assertSame('array{a?: 1, b?: 1}', $array->describe(VerbosityLevel::precise())); + } + + public function testOptionalNullOffsetOnEmptyArrayIsPossiblyEmpty(): void + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->setOffsetValueType(null, new ConstantIntegerType(1), true); + + $array = $builder->getArray(); + $this->assertSame('array{0?: 1}', $array->describe(VerbosityLevel::precise())); + } + }