diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index ebed9eedc2..eddede8c82 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -17,6 +17,7 @@ use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\Accessory\AccessoryUppercaseStringType; +use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Constant\ConstantArrayType; @@ -875,6 +876,17 @@ public function tryRemove(Type $typeToRemove): ?Type return new ConstantArrayType([], []); } + if ($typeToRemove instanceof HasOffsetType) { + return $this->unsetOffset($typeToRemove->getOffsetType()); + } + + if ($typeToRemove instanceof HasOffsetValueType) { + if ($typeToRemove->getValueType()->isSuperTypeOf($this->itemType)->yes()) { + return $this->unsetOffset($typeToRemove->getOffsetType()); + } + return null; + } + return null; } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 8850f7f45a..dc40b61382 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1836,11 +1836,12 @@ public function tryRemove(Type $typeToRemove): ?Type } if ($typeToRemove instanceof HasOffsetType || $typeToRemove instanceof HasOffsetValueType) { + if ($typeToRemove instanceof HasOffsetValueType + && !$typeToRemove->getValueType()->isSuperTypeOf($this->getOffsetValueType($typeToRemove->getOffsetType()))->yes()) { + return null; + } + $unsetResult = $this->unsetOffset($typeToRemove->getOffsetType(), true); - // When the source was definitely a list but the post-unset shape - // definitely isn't (e.g. unsetting a non-optional leading key - // creates a hole), no value of $this could have lacked the - // removed key — the subtraction yields the empty set. if ($this->isList->yes() && $unsetResult->isList()->no()) { return new NeverType(); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-9461.php b/tests/PHPStan/Analyser/nsrt/bug-9461.php new file mode 100644 index 0000000000..2e115cc381 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-9461.php @@ -0,0 +1,100 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug9461; + +use function array_key_exists; +use function PHPStan\Testing\assertType; + +function test(): void { + /** @var array<0|string, mixed> */ + $defaults = ['test', 'a' => 1]; + + assertType('array<0|string, mixed>', $defaults); + if (!array_key_exists(0, $defaults)) { + assertType('array', $defaults); + } else { + assertType('non-empty-array<0|string, mixed>&hasOffset(0)', $defaults); + } +} + +function testStringKey(): void { + /** @var array<'foo'|int, mixed> */ + $arr = []; + + if (!array_key_exists('foo', $arr)) { + assertType('array', $arr); + } else { + assertType("non-empty-array<'foo'|int, mixed>&hasOffset('foo')", $arr); + } +} + +function testGenericIntKey(): void { + /** @var array */ + $arr = []; + + if (!array_key_exists(0, $arr)) { + assertType('array|int<1, max>, mixed>', $arr); + } +} + +function testKeyExists(): void { + /** @var array<0|string, mixed> */ + $arr = []; + + if (!key_exists(0, $arr)) { + assertType('array', $arr); + } +} + +function testConstantArrayValueTypeGuard(): void { + $arr = ['hello', 'a' => 'world']; + assertType("array{0: 'hello', a: 'world'}", $arr); + + if (!array_key_exists(0, $arr)) { + assertType('*NEVER*', $arr); + } +} + +function testNonEmptyArray(): void { + /** @var non-empty-array<0|string, mixed> */ + $arr = ['test']; + + assertType('non-empty-array<0|string, mixed>', $arr); + if (!array_key_exists(0, $arr)) { + assertType('non-empty-array', $arr); + } else { + assertType('non-empty-array<0|string, mixed>&hasOffset(0)', $arr); + } +} + +function testIssetDoesNotNarrowKeyType(): void { + /** @var array<0|string, mixed> */ + $arr = []; + + if (!isset($arr[0])) { + // isset also checks for null, so !isset doesn't mean key doesn't exist + assertType('array<0|string, mixed>', $arr); + } else { + assertType('non-empty-array<0|string, mixed>&hasOffsetValue(0, mixed~null)', $arr); + } +} + +/** + * @param array<0|string, mixed>|string $label + * @return array + */ +function makeSeedFromLabel($label = []): array +{ + $defaults = is_array($label) ? $label : [$label]; + assertType('array<0|string, mixed>', $defaults); + + if (array_key_exists(0, $defaults)) { + $defaults['content'] = $defaults[0]; + unset($defaults[0]); + } + assertType('array', $defaults); + + return $defaults; +} diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index f325c638da..29a074fc1e 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -5575,6 +5575,57 @@ public static function dataRemove(): array IntersectionType::class, 'non-empty-array&oversized-array', ], + [ + new ArrayType(new UnionType([new ConstantIntegerType(0), new StringType()]), new MixedType()), + new HasOffsetValueType(new ConstantIntegerType(0), new MixedType()), + ArrayType::class, + 'array', + ], + [ + new ArrayType(new UnionType([new ConstantIntegerType(0), new StringType()]), new IntegerType()), + new HasOffsetValueType(new ConstantIntegerType(0), new IntegerType()), + ArrayType::class, + 'array', + ], + [ + new ArrayType(new UnionType([new ConstantIntegerType(0), new StringType()]), new MixedType()), + new HasOffsetValueType(new ConstantIntegerType(0), new StringType()), + ArrayType::class, + 'array<0|string, mixed>', + ], + [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantStringType('a')], + [new IntegerType(), new StringType()], + [1], + [0, 1], + ), + new HasOffsetValueType(new ConstantIntegerType(0), new IntegerType()), + ConstantArrayType::class, + 'array{a?: string}', + ], + [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantStringType('a')], + [new IntegerType(), new StringType()], + [1], + [0, 1], + ), + new HasOffsetValueType(new ConstantIntegerType(0), new StringType()), + ConstantArrayType::class, + "array{0?: int, a?: string}", + ], + [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantStringType('a')], + [new MixedType(), new StringType()], + [1], + [0, 1], + ), + new HasOffsetValueType(new ConstantIntegerType(0), new MixedType()), + ConstantArrayType::class, + 'array{a?: string}', + ], ]; }