From 106a23764bf050c2aa5610476026e393620e3b36 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 14 Aug 2026 03:29:03 +0900 Subject: [PATCH 1/4] Deduplicate ConstantArrayType's unsealed key type normalization getIterableKeyType(), getFirstIterableKeyType() and getLastIterableKeyType() each spelled out the same "implicit mixed tail means array-key" normalization. The first/last pair was also missing the reportUnsafeArrayStringKeyCasting cast that getIterableKeyType() right next to them already applied. Co-authored-by: Claude Opus 5 --- src/Type/Constant/ConstantArrayType.php | 52 ++++++++++++++----------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index f0c6f19c41..cde15c617a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -301,13 +301,8 @@ public function getIterableKeyType(): Type $keyType = new UnionType($this->keyTypes); } - if ($this->isUnsealed()->yes() && $this->unsealed !== null) { - $unsealedKeyType = $this->unsealed[0]; - if ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) { - $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); - } elseif ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) { - $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); - } + $unsealedKeyType = $this->getUnsealedKeyType(); + if ($unsealedKeyType !== null) { $keyType = TypeCombinator::union($keyType, $unsealedKeyType); } @@ -2143,17 +2138,12 @@ public function getFirstIterableKeyType(): Type } } - if ($this->isUnsealed()->yes() && $this->unsealed !== null) { - $unsealedKeyType = $this->unsealed[0]; - if ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) { - $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); - } elseif ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) { - $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); - } + $unsealedKeyType = $this->getUnsealedKeyType(); + if ($unsealedKeyType !== null) { $keyTypes[] = $unsealedKeyType; } - return TypeCombinator::union(...$keyTypes); + return UnsafeArrayStringKeyCastingTraverser::castKeyType(TypeCombinator::union(...$keyTypes)); } public function getLastIterableKeyType(): Type @@ -2166,17 +2156,33 @@ public function getLastIterableKeyType(): Type } } - if ($this->isUnsealed()->yes() && $this->unsealed !== null) { - $unsealedKeyType = $this->unsealed[0]; - if ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) { - $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); - } elseif ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) { - $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); - } + $unsealedKeyType = $this->getUnsealedKeyType(); + if ($unsealedKeyType !== null) { $keyTypes[] = $unsealedKeyType; } - return TypeCombinator::union(...$keyTypes); + return UnsafeArrayStringKeyCastingTraverser::castKeyType(TypeCombinator::union(...$keyTypes)); + } + + /** + * The unsealed tail's key type, with an implicit `mixed` spelled out as the + * `array-key` it really is. Null when this shape is sealed. + */ + private function getUnsealedKeyType(): ?Type + { + if (!$this->isUnsealed()->yes() || $this->unsealed === null) { + return null; + } + + $unsealedKeyType = $this->unsealed[0]; + if ( + ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) + || ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) + ) { + return (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + + return $unsealedKeyType; } public function getFirstIterableValueType(): Type From d56197ad6f81e6760597cd064b1a27a78e328b99 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 14 Aug 2026 03:29:21 +0900 Subject: [PATCH 2/4] Run array_fill_keys() values through toArrayKey() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fillKeysArray() on a general array only called toString(), so array_fill_keys() disagreed with array_flip() right next to it: `list` gave `array` instead of `array`, and `list` kept a numeric-string key instead of the int PHP actually stores. Co-authored-by: Claude Opus 5 --- src/Type/ArrayType.php | 4 +++- tests/PHPStan/Analyser/nsrt/array-fill-keys.php | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 300f5ae787..fe1b0f6731 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -495,7 +495,9 @@ public function fillKeysArray(Type $valueType): Type return $stringKeyType; } - return new ArrayType($stringKeyType, $valueType); + // The values become keys, so they go through the same cast as any + // other written key — just like flipArray() below. + return new ArrayType($stringKeyType->toArrayKey(), $valueType); } return new ArrayType($itemType, $valueType); diff --git a/tests/PHPStan/Analyser/nsrt/array-fill-keys.php b/tests/PHPStan/Analyser/nsrt/array-fill-keys.php index 231b2dcbe5..40324bea67 100644 --- a/tests/PHPStan/Analyser/nsrt/array-fill-keys.php +++ b/tests/PHPStan/Analyser/nsrt/array-fill-keys.php @@ -91,7 +91,7 @@ function withNotConstantArray(array $foo, array $bar, array $baz, array $floats, assertType("array", array_fill_keys($foo, null)); assertType("array", array_fill_keys($bar, null)); assertType("array<'foo', null>", array_fill_keys($baz, null)); - assertType("array", array_fill_keys($floats, null)); + assertType("array", array_fill_keys($floats, null)); assertType("array", array_fill_keys($mixed, null)); assertType('array', array_fill_keys($list, null)); assertType('*ERROR*', array_fill_keys($objectsWithoutToString, null)); From e76664a65230c301111f0c861c0bb306eeca4d4b Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 14 Aug 2026 05:21:10 +0900 Subject: [PATCH 3/4] Widen the key type array_key_first() and array_key_last() return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP casts a decimal-integer string array key ("123") to int, so `array_key_first([$string => null])` is not necessarily a string. PHPStan inferred `string` for it and reported `is_int()` on the result as always false. UnsafeArrayStringKeyCastingTraverser already modelled that cast, but only for the key type an array *has* — a type that also decides how the array describes itself and what it accepts, which is why only `reportUnsafeArrayStringKeyCasting: detect` widens it. Widening it with the toggle off turns `array` into `array` everywhere. castReadKeyType() is the second entry point, for a key that leaves the array as a value of its own. With the toggle off it widens `string` to the benevolent `(int|string)`, so neither branch reports anything; `detect` and `prevent` keep the types they have today. unionWithReadKeyType() adds the `null` an empty array gives back without losing the benevolence on the way. The remaining accessors follow in the next commit. Co-authored-by: Claude Opus 5 --- ...rayFirstLastDynamicReturnTypeExtension.php | 9 +- .../UnsafeArrayStringKeyCastingTraverser.php | 82 +++++++++++++++++-- tests/PHPStan/Analyser/nsrt/bug-14245.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-15073.php | 49 +++++++++++ .../PHPStan/Analyser/nsrt/php73_functions.php | 8 +- 5 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15073.php diff --git a/src/Type/Php/ArrayFirstLastDynamicReturnTypeExtension.php b/src/Type/Php/ArrayFirstLastDynamicReturnTypeExtension.php index 859f36f1f0..248fdbb963 100644 --- a/src/Type/Php/ArrayFirstLastDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayFirstLastDynamicReturnTypeExtension.php @@ -9,6 +9,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\NullType; +use PHPStan\Type\Traverser\UnsafeArrayStringKeyCastingTraverser; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function count; @@ -46,8 +47,12 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, switch ($functionReflection->getName()) { case 'array_key_first': case 'array_key_last': - $resultType = $argType->getIterableKeyType(); - break; + $keyType = $argType->getIterableKeyType(); + if ($iterableAtLeastOnce->yes()) { + return UnsafeArrayStringKeyCastingTraverser::castReadKeyType($keyType); + } + + return UnsafeArrayStringKeyCastingTraverser::unionWithReadKeyType($keyType, new NullType()); case 'array_first': case 'array_last': $resultType = $argType->getIterableValueType(); diff --git a/src/Type/Traverser/UnsafeArrayStringKeyCastingTraverser.php b/src/Type/Traverser/UnsafeArrayStringKeyCastingTraverser.php index 2e5b5b1edb..f1b9725400 100644 --- a/src/Type/Traverser/UnsafeArrayStringKeyCastingTraverser.php +++ b/src/Type/Traverser/UnsafeArrayStringKeyCastingTraverser.php @@ -4,6 +4,7 @@ use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; +use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\IntegerType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -12,23 +13,82 @@ use PHPStan\Type\UnionType; /** - * Under `reportUnsafeArrayStringKeyCasting: detect`, PHP casts a decimal-integer - * string array key ("123") to int when iterating, so the iterable key type widens - * from `string` to `int | non-decimal-int-string`. Shared by ArrayType and - * ConstantArrayType so both representations agree — otherwise comparing a general - * array (cast key) against a constant-array shape (raw key) yields a spurious - * `Maybe`. + * PHP casts a decimal-integer string array key ("123") to int, so an array with + * a `string` key type can hand back an int. There are two places that matters, + * and they widen differently on purpose. + * + * {@see self::castKeyType()} widens the key type an array *has*. That type also + * decides how the array describes itself and what it accepts, so only + * `reportUnsafeArrayStringKeyCasting: detect` widens there — it opts into the + * resulting reports. Under `prevent` there is nothing to do, because PHPDoc + * string key types are narrowed to `non-decimal-int-string` when resolved. + * + * {@see self::castReadKeyType()} widens a key *taken out* of an array and handed + * back as a value of its own — `array_key_first()`, `array_keys()`, `key()`, + * `array_flip()`, … With the toggle off it widens `string` to the benevolent + * `(int|string)`, which stops `array_key_first([$string => null])` from looking + * like a certain `string` without making either branch report an error. + * + * `foreach` keys are deliberately not widened with the toggle off: the key + * usually goes straight back into another array (`$result[$k] = …`), and a + * benevolent `(int|string)` key collapses that array to `array`. + * `detect` is the level that gets accurate `foreach` keys. + * + * Both are shared by ArrayType and ConstantArrayType so the two representations + * agree — otherwise comparing a general array (cast key) against a + * constant-array shape (raw key) yields a spurious `Maybe`. */ final class UnsafeArrayStringKeyCastingTraverser implements TypeTraverserCallable { + private function __construct(private bool $precise) + { + } + public static function castKeyType(Type $keyType): Type { if (ReportUnsafeArrayStringKeyCastingToggle::getLevel() !== ReportUnsafeArrayStringKeyCastingToggle::DETECT) { return $keyType; } - return TypeTraverser::map($keyType, new self()); + return TypeTraverser::map($keyType, new self(true)); + } + + public static function castReadKeyType(Type $keyType): Type + { + $level = ReportUnsafeArrayStringKeyCastingToggle::getLevel(); + if ($level !== null) { + // `detect` already widened the key type the array carries, and `prevent` + // made sure it can't hold a decimal-integer string in the first place. + return self::castKeyType($keyType); + } + + // A key type that already covers int has nothing to gain from the widening. + // Leaving it alone also keeps it out of a BenevolentUnionType, so what is + // checked against it stays as strict as it is today. + if ($keyType->isSuperTypeOf(new IntegerType())->yes()) { + return $keyType; + } + + return TypeTraverser::map($keyType, new self(false)); + } + + /** + * Adds the "there is no key" result an accessor returns for an empty array + * (`null` for array_key_first(), `false` for array_search(), …). + * + * TypeCombinator alone would drop the benevolence of a widened key type and + * start reporting on the very code the widening exists to leave alone. + */ + public static function unionWithReadKeyType(Type $keyType, Type $noKeyType): Type + { + $keyType = self::castReadKeyType($keyType); + $union = TypeCombinator::union($keyType, $noKeyType); + if ($keyType instanceof BenevolentUnionType && $union instanceof UnionType && !$union instanceof BenevolentUnionType) { + return new BenevolentUnionType($union->getTypes()); + } + + return $union; } /** @@ -41,6 +101,14 @@ public function traverse(Type $type, callable $traverse): Type } if ($type->isString()->yes() && !$type->isDecimalIntegerString()->no()) { + if (!$this->precise) { + if ($type->isDecimalIntegerString()->yes()) { + return new IntegerType(); + } + + return new BenevolentUnionType([new IntegerType(), $type]); + } + return TypeCombinator::union( new IntegerType(), TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)), diff --git a/tests/PHPStan/Analyser/nsrt/bug-14245.php b/tests/PHPStan/Analyser/nsrt/bug-14245.php index 63333b5de2..2b93e6d574 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14245.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14245.php @@ -104,7 +104,7 @@ function keyDifferentArray(array $arr): void { $list = foo(); assertType('list', $list); $list[array_key_first($arr)] = 37; - assertType('non-empty-array', $list); + assertType('non-empty-array', $list); } function overwriteArraySearch($needle): void { diff --git a/tests/PHPStan/Analyser/nsrt/bug-15073.php b/tests/PHPStan/Analyser/nsrt/bug-15073.php new file mode 100644 index 0000000000..63834eaf26 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15073.php @@ -0,0 +1,49 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug15073; + +use function PHPStan\Testing\assertType; + +/** + * @param array-key $array_key + * @param numeric-string $numeric_string + * @param decimal-int-string $decimal_string + * @param non-decimal-int-string $non_decimal_string + */ +function readKeys(int|string $int_or_str, int|string $array_key, string $string, string $numeric_string, string $decimal_string, string $non_decimal_string): void +{ + assertType('int|string', array_key_first([$int_or_str => null])); + assertType('(int|string)', array_key_first([$array_key => null])); + assertType('(int|string)', array_key_first([$string => null])); + assertType('int|numeric-string', array_key_first([$numeric_string => null])); + assertType('int', array_key_first([$decimal_string => null])); + assertType('non-decimal-int-string', array_key_first([$non_decimal_string => null])); + + assertType('(int|string)', array_key_last([$string => null])); +} + +/** + * @param non-empty-array $intOrString + * @param non-empty-array|string, int> $partlyInt + */ +function keyTypesThatStopShortOfTheWidening(array $intOrString, array $partlyInt): void +{ + // int|string already covers int, so it is left alone and stays strict + assertType('int|string', array_key_first($intOrString)); + + // int<0, max>|string covers only part of int, so the string half still widens + assertType('(int|string)', array_key_first($partlyInt)); +} + +function isDecimalIntString(mixed $val): bool +{ + if (!is_string($val)) { + return false; + } + + assertType('(int|string)', array_key_first([$val => null])); + + return is_int(array_key_first([$val => null])); +} diff --git a/tests/PHPStan/Analyser/nsrt/php73_functions.php b/tests/PHPStan/Analyser/nsrt/php73_functions.php index 09775c21ef..d434709226 100644 --- a/tests/PHPStan/Analyser/nsrt/php73_functions.php +++ b/tests/PHPStan/Analyser/nsrt/php73_functions.php @@ -54,12 +54,12 @@ public function doFoo( assertType('mixed', json_decode($mixed)); assertType('mixed', json_decode($mixed, false, 512, JSON_THROW_ON_ERROR | JSON_NUMERIC_CHECK)); assertType('mixed', json_decode($mixed, false, 512, $integer | JSON_THROW_ON_ERROR | JSON_NUMERIC_CHECK)); - assertType('int|string|null', array_key_first($mixedArray)); - assertType('int|string|null', array_key_last($mixedArray)); + assertType('(int|string|null)', array_key_first($mixedArray)); + assertType('(int|string|null)', array_key_last($mixedArray)); assertType('(int|string)', array_key_first($nonEmptyArray)); assertType('(int|string)', array_key_last($nonEmptyArray)); - assertType('string|null', array_key_first($arrayWithStringKeys)); - assertType('string|null', array_key_last($arrayWithStringKeys)); + assertType('(int|string|null)', array_key_first($arrayWithStringKeys)); + assertType('(int|string|null)', array_key_last($arrayWithStringKeys)); assertType('null', array_key_first($emptyArray)); assertType('null', array_key_last($emptyArray)); assertType('0|1|2', array_key_first($literalArray)); From 5c531b3e26a9d31077679ff6b22ec89fedd02e2a Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 14 Aug 2026 13:02:52 +0900 Subject: [PATCH 4/4] Cast array keys that are handed back as values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP casts a decimal-integer string array key ("123") to int, so `array_key_first([$string => null])` is not necessarily a string. PHPStan inferred `string` for it and reported `is_int()` on the result as always false. array_key_first() and array_key_last() went first; call castReadKeyType() from the rest of the accessors that hand a key back as a value of its own: array_keys(), key(), array_find_key(), array_search() and the values of array_flip(). `foreach` keys stay unwidened with the toggle off: the key usually goes straight back into another array, and a benevolent `(int|string)` key collapses that array to `array`. `detect` remains the level with accurate `foreach` keys. The rename in OptimizedDirectorySourceLocatorFactory is fallout: with the wider `array_keys()` type PHPStan now proves `$file` defined inside `if ($findInFiles !== [])`, which is correct and makes strict-rules flag the reuse. The `@var int|string` workarounds over `key([$string => null])` in ArgumentsNormalizer and ConstantStringType are what this bug looks like from the inside; both are redundant now and go away with their baseline entries. The two calls also read better as array_key_first(), which says what they are after. Closes https://github.com/phpstan/phpstan/issues/15073 Co-authored-by: Claude Opus 5 --- phpstan-baseline.neon | 12 -- src/Analyser/ArgumentsNormalizer.php | 5 +- ...OptimizedDirectorySourceLocatorFactory.php | 6 +- src/Type/ArrayType.php | 7 +- src/Type/Constant/ConstantArrayType.php | 33 ++++- src/Type/Constant/ConstantStringType.php | 5 +- ...rrayFindKeyFunctionReturnTypeExtension.php | 4 +- .../ArrayKeyDynamicReturnTypeExtension.php | 6 +- ...unsafe-array-string-key-casting-detect.php | 22 ++++ .../PHPStan/Analyser/nsrt/array-find-key.php | 4 +- tests/PHPStan/Analyser/nsrt/array-flip.php | 6 +- .../PHPStan/Analyser/nsrt/array-functions.php | 20 +-- .../Analyser/nsrt/array-search-existing.php | 4 +- .../Analyser/nsrt/array-search-php7.php | 6 +- .../Analyser/nsrt/array-search-php8.php | 6 +- tests/PHPStan/Analyser/nsrt/array-search.php | 6 +- tests/PHPStan/Analyser/nsrt/bug-11928.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-14630.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-15073.php | 119 +++++++++++++++++- .../Analyser/nsrt/unsealed-array-shapes.php | 8 +- .../Analyser/nsrt/unsealed-derivations.php | 2 +- ...nexistentOffsetInArrayDimFetchRuleTest.php | 2 +- 22 files changed, 214 insertions(+), 77 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 76efc63f39..3bfb3e5ccf 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -12,12 +12,6 @@ parameters: count: 1 path: src/Analyser/AnalyserResultFinalizer.php - - - rawMessage: PHPDoc tag @var with type int|string is not subtype of type string. - identifier: varTag.type - count: 1 - path: src/Analyser/ArgumentsNormalizer.php - - rawMessage: Casting to string something that's already string. identifier: cast.useless @@ -1053,12 +1047,6 @@ parameters: count: 1 path: src/Type/Constant/ConstantStringType.php - - - rawMessage: PHPDoc tag @var with type int|string is not subtype of type string. - identifier: varTag.type - count: 1 - path: src/Type/Constant/ConstantStringType.php - - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.' identifier: phpstanApi.instanceofType diff --git a/src/Analyser/ArgumentsNormalizer.php b/src/Analyser/ArgumentsNormalizer.php index ff4298756f..39979d79ac 100644 --- a/src/Analyser/ArgumentsNormalizer.php +++ b/src/Analyser/ArgumentsNormalizer.php @@ -21,11 +21,11 @@ use PHPStan\Type\Constant\ConstantArrayType; use function array_is_list; use function array_key_exists; +use function array_key_first; use function array_keys; use function array_values; use function count; use function is_string; -use function key; use function ksort; use function max; use function sprintf; @@ -149,8 +149,7 @@ public static function reorderCallUserFuncArrayArguments( foreach ($argsArrayArg->value->items as $item) { $key = null; if ($item->key instanceof String_) { - /** @var int|string $key */ - $key = key([$item->key->value => null]); + $key = array_key_first([$item->key->value => null]); if ($key === '') { return null; } diff --git a/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocatorFactory.php b/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocatorFactory.php index a26209d4a4..122355fcea 100644 --- a/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocatorFactory.php +++ b/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocatorFactory.php @@ -213,9 +213,9 @@ private function createCachedDirectorySourceLocator(array $fileHashes, string $c if ($findInFiles !== []) { $cacheModified = true; - foreach ($this->symbolFinderInFiles->findSymbols($findInFiles, $this->phpVersion->supportsEnums()) as $file => [$newClasses, $newFunctions, $newConstants]) { - $newHash = $originalFileHashes[$file]; - $cached[$file] = [$newHash, $newClasses, $newFunctions, $newConstants]; + foreach ($this->symbolFinderInFiles->findSymbols($findInFiles, $this->phpVersion->supportsEnums()) as $scannedFile => [$newClasses, $newFunctions, $newConstants]) { + $newHash = $originalFileHashes[$scannedFile]; + $cached[$scannedFile] = [$newHash, $newClasses, $newFunctions, $newConstants]; } } diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index fe1b0f6731..777e17a4d9 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -222,7 +222,7 @@ public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict public function getKeysArray(): Type { - return TypeCombinator::intersect(new self(new IntegerType(), $this->getIterableKeyType()), new AccessoryArrayListType()); + return TypeCombinator::intersect(new self(new IntegerType(), UnsafeArrayStringKeyCastingTraverser::castReadKeyType($this->getIterableKeyType())), new AccessoryArrayListType()); } public function getValuesArray(): Type @@ -505,7 +505,8 @@ public function fillKeysArray(Type $valueType): Type public function flipArray(): Type { - return new self($this->getIterableValueType()->toArrayKey(), $this->getIterableKeyType()); + // The keys become values, so they're subject to PHP's array key cast. + return new self($this->getIterableValueType()->toArrayKey(), UnsafeArrayStringKeyCastingTraverser::castReadKeyType($this->getIterableKeyType())); } public function intersectKeyArray(Type $otherArraysType): Type @@ -587,7 +588,7 @@ public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Typ return new ConstantBooleanType(false); } - return TypeCombinator::union($this->getIterableKeyType(), new ConstantBooleanType(false)); + return UnsafeArrayStringKeyCastingTraverser::unionWithReadKeyType($this->getIterableKeyType(), new ConstantBooleanType(false)); } public function shiftArray(): Type diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index cde15c617a..ff4e96681b 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1602,7 +1602,8 @@ public function flipArray(): Type $offsetType = $valueType->toArrayKey(); $builder->setOffsetValueType( $offsetType, - $keyType, + // The keys become values, so they're subject to PHP's array key cast. + UnsafeArrayStringKeyCastingTraverser::castReadKeyType($keyType), $this->isOptionalKey($i) || count($offsetType->getConstantScalarTypes()) > 1, ); } @@ -1610,7 +1611,7 @@ public function flipArray(): Type if ($this->isUnsealed()->yes() && $this->unsealed !== null) { [$unsealedKey, $unsealedValue] = $this->unsealed; $flippedKey = $unsealedValue->toArrayKey(); - $flippedValue = $unsealedKey; + $flippedValue = UnsafeArrayStringKeyCastingTraverser::castReadKeyType($unsealedKey); // For a non-finite tail key (e.g. `string`), install the // unsealed extras first; setOffsetValueType then widens any // overlapping explicit values with the tail's value type. @@ -1731,11 +1732,14 @@ public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Typ } if (count($matches) > 0) { + // The found key becomes a value of its own, so it's subject to PHP's + // array key cast. + $matchedKeyType = TypeCombinator::union(...$matches); if ($hasIdenticalValue) { - return TypeCombinator::union(...$matches); + return UnsafeArrayStringKeyCastingTraverser::castReadKeyType($matchedKeyType); } - return TypeCombinator::union(new ConstantBooleanType(false), ...$matches); + return UnsafeArrayStringKeyCastingTraverser::unionWithReadKeyType($matchedKeyType, new ConstantBooleanType(false)); } return new ConstantBooleanType(false); @@ -2443,7 +2447,7 @@ private function degradeToGeneralArray(): Type public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict): Type { - $keysArray = $this->getKeysOrValuesArray($this->keyTypes, $this->unsealed[0] ?? null); + $keysArray = $this->getReadKeysArray(); return new IntersectionType([ new ArrayType( @@ -2456,7 +2460,24 @@ public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict public function getKeysArray(): self { - return $this->getKeysOrValuesArray($this->keyTypes, $this->unsealed[0] ?? null); + return $this->getReadKeysArray(); + } + + /** + * The keys as a list of values - they've left the array, so they're subject + * to PHP's array key cast. + */ + private function getReadKeysArray(): self + { + $unsealedKeyType = $this->unsealed[0] ?? null; + + return $this->getKeysOrValuesArray( + array_map( + static fn (Type $keyType): Type => UnsafeArrayStringKeyCastingTraverser::castReadKeyType($keyType), + $this->keyTypes, + ), + $unsealedKeyType !== null ? UnsafeArrayStringKeyCastingTraverser::castReadKeyType($unsealedKeyType) : null, + ); } public function getValuesArray(): self diff --git a/src/Type/Constant/ConstantStringType.php b/src/Type/Constant/ConstantStringType.php index 5994a94556..039e32a822 100644 --- a/src/Type/Constant/ConstantStringType.php +++ b/src/Type/Constant/ConstantStringType.php @@ -49,13 +49,13 @@ use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use function addcslashes; +use function array_key_first; use function array_unique; use function array_values; use function in_array; use function is_float; use function is_int; use function is_numeric; -use function key; use function strlen; use function strtolower; use function strtoupper; @@ -385,8 +385,7 @@ public function toArrayKey(): Type return $this->arrayKeyType; } - /** @var int|string $offsetValue */ - $offsetValue = key([$this->value => null]); + $offsetValue = array_key_first([$this->value => null]); if ($offsetValue === $this->value) { return $this; diff --git a/src/Type/Php/ArrayFindKeyFunctionReturnTypeExtension.php b/src/Type/Php/ArrayFindKeyFunctionReturnTypeExtension.php index 9a02e63d99..40570e63ba 100644 --- a/src/Type/Php/ArrayFindKeyFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayFindKeyFunctionReturnTypeExtension.php @@ -8,8 +8,8 @@ use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\NullType; +use PHPStan\Type\Traverser\UnsafeArrayStringKeyCastingTraverser; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; use function count; #[AutowiredService] @@ -33,7 +33,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return null; } - return TypeCombinator::union($arrayType->getIterableKeyType(), new NullType()); + return UnsafeArrayStringKeyCastingTraverser::unionWithReadKeyType($arrayType->getIterableKeyType(), new NullType()); } } diff --git a/src/Type/Php/ArrayKeyDynamicReturnTypeExtension.php b/src/Type/Php/ArrayKeyDynamicReturnTypeExtension.php index 3499b54052..f8c21e8547 100644 --- a/src/Type/Php/ArrayKeyDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayKeyDynamicReturnTypeExtension.php @@ -8,8 +8,8 @@ use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\NullType; +use PHPStan\Type\Traverser\UnsafeArrayStringKeyCastingTraverser; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; #[AutowiredService] final class ArrayKeyDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension @@ -35,10 +35,10 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $keyType = $argType->getIterableKeyType(); if ($iterableAtLeastOnce->yes()) { - return $keyType; + return UnsafeArrayStringKeyCastingTraverser::castReadKeyType($keyType); } - return TypeCombinator::union($keyType, new NullType()); + return UnsafeArrayStringKeyCastingTraverser::unionWithReadKeyType($keyType, new NullType()); } } diff --git a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php index 15c9387e99..064ede1429 100644 --- a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-detect.php @@ -127,3 +127,25 @@ public function doBaz(array $a): void } } + +class ReadKeys +{ + + /** + * @param array $a + * @param non-empty-array $b + */ + public function doFoo(array $a, array $b, string $s): void + { + assertType('int|non-decimal-int-string', array_key_first([$s => null])); + assertType('int|non-decimal-int-string', array_key_last([$s => null])); + assertType('non-empty-list', array_keys([$s => null])); + + assertType('int|non-decimal-int-string|null', array_key_first($a)); + assertType('int|non-decimal-int-string', array_key_last($b)); + assertType('list', array_keys($a)); + assertType('int|non-decimal-int-string|null', key($a)); + assertType('int|non-decimal-int-string|false', array_search(1, $a, true)); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/array-find-key.php b/tests/PHPStan/Analyser/nsrt/array-find-key.php index 5caf828f53..2626cd15d9 100644 --- a/tests/PHPStan/Analyser/nsrt/array-find-key.php +++ b/tests/PHPStan/Analyser/nsrt/array-find-key.php @@ -33,8 +33,8 @@ function array_find_key(array $array, callable $callback) */ function testMixed(array $array, callable $callback): void { - assertType('int|string|null', array_find_key($array, $callback)); - assertType('int|string|null', array_find_key($array, 'is_int')); + assertType('(int|string|null)', array_find_key($array, $callback)); + assertType('(int|string|null)', array_find_key($array, 'is_int')); } /** diff --git a/tests/PHPStan/Analyser/nsrt/array-flip.php b/tests/PHPStan/Analyser/nsrt/array-flip.php index 9d4184f32a..8f1707bc87 100644 --- a/tests/PHPStan/Analyser/nsrt/array-flip.php +++ b/tests/PHPStan/Analyser/nsrt/array-flip.php @@ -72,17 +72,17 @@ function foo10(array $array) { if (array_key_exists('foo', $array)) { assertType('non-empty-array&hasOffset(\'foo\')', $array); - assertType('non-empty-array', array_flip($array)); + assertType('non-empty-array', array_flip($array)); } if (array_key_exists('foo', $array) && is_int($array['foo'])) { assertType("non-empty-array&hasOffsetValue('foo', int)", $array); - assertType('non-empty-array', array_flip($array)); + assertType('non-empty-array', array_flip($array)); } if (array_key_exists('foo', $array) && $array['foo'] === 17) { assertType("non-empty-array&hasOffsetValue('foo', 17)", $array); - assertType("non-empty-array&hasOffsetValue(17, 'foo')", array_flip($array)); + assertType("non-empty-array&hasOffsetValue(17, 'foo')", array_flip($array)); } if ( diff --git a/tests/PHPStan/Analyser/nsrt/array-functions.php b/tests/PHPStan/Analyser/nsrt/array-functions.php index dbbbe0cf76..9c56f39bf9 100644 --- a/tests/PHPStan/Analyser/nsrt/array-functions.php +++ b/tests/PHPStan/Analyser/nsrt/array-functions.php @@ -241,7 +241,7 @@ assertType('array{1, 2}', array_keys($integerKeys)); assertType('array{\'foo\', \'bar\'}', array_keys($stringKeys)); assertType('array{\'foo\', 1}', array_keys($stringOrIntegerKeys)); -assertType('list', array_keys($generalStringKeys)); +assertType('list<(int|string)>', array_keys($generalStringKeys)); assertType('array{\'foo\', stdClass}', array_values($integerKeys)); assertType('list', array_values($generalStringKeys)); assertType('array{foo: stdClass, 0: stdClass}', array_merge($stringOrIntegerKeys)); @@ -291,13 +291,13 @@ assertType('1|\'foo\'|false', array_search(new stdClass, $stringOrIntegerKeys, true)); assertType('\'foo\'', array_search('foo', $stringKeys, true)); assertType('int|false', array_search(new DateTimeImmutable(), $generalDateTimeValues, true)); -assertType('string|false', array_search(9, $generalStringKeys, true)); -assertType('string|false', array_search(9, $generalStringKeys, false)); -assertType('string|false', array_search(9, $generalStringKeys)); +assertType('(int|string|false)', array_search(9, $generalStringKeys, true)); +assertType('(int|string|false)', array_search(9, $generalStringKeys, false)); +assertType('(int|string|false)', array_search(9, $generalStringKeys)); assertType('*NEVER*', array_search(999, $integer, true)); assertType('false', array_search(new stdClass, $generalStringKeys, true)); -assertType('int|string|false', array_search($mixed, $array, true)); -assertType('int|string|false', array_search($mixed, $array, false)); +assertType('(int|string|false)', array_search($mixed, $array, true)); +assertType('(int|string|false)', array_search($mixed, $array, false)); assertType('\'a\'|\'b\'|false', array_search($string, ['a' => 'A', 'b' => 'B'], true)); assertType('false', array_search($integer, ['a' => 'A', 'b' => 'B'], true)); assertType('\'foo\'|false', array_search($generalIntegerOrString, $stringKeys, true)); @@ -312,9 +312,9 @@ assertType('*NEVER*', array_search('id', false)); assertType('int|string|false', array_search('id', $thisDoesNotExistAndIsMixed, true)); assertType('int|string|false', array_search('id', doFoo() ? $thisDoesNotExistAndIsMixedInUnion : false, true)); -assertType('int|string|false', array_search(1, $generalIntegers, true)); -assertType('int|string|false', array_search(1, $generalIntegers, false)); -assertType('int|string|false', array_search(1, $generalIntegers)); +assertType('(int|string|false)', array_search(1, $generalIntegers, true)); +assertType('(int|string|false)', array_search(1, $generalIntegers, false)); +assertType('(int|string|false)', array_search(1, $generalIntegers)); assertType('array', array_slice($generalStringKeys, 0)); assertType('array', array_slice($generalStringKeys, 1)); assertType('array', array_slice($generalStringKeys, 1, null, true)); @@ -348,7 +348,7 @@ assertType('array{4: \'foo\', 1: \'bar\', baz: \'qux\', 0: \'quux\', quuz: \'corge\', 5: \'grault\'}', $slicedOffsetWithKeys); assertType('0|1', key($mixedValues)); assertType('int|null', key($falsey)); -assertType('string|null', key($generalStringKeys)); +assertType('(int|string|null)', key($generalStringKeys)); assertType('int|string|null', key($generalIntegerOrStringKeysMixedValues)); assertType('\'foo\'', $poppedFoo); assertType('int', array_rand([1 => 1, 2 => "2"])); diff --git a/tests/PHPStan/Analyser/nsrt/array-search-existing.php b/tests/PHPStan/Analyser/nsrt/array-search-existing.php index 2289b5c991..5c5b3aa1e1 100644 --- a/tests/PHPStan/Analyser/nsrt/array-search-existing.php +++ b/tests/PHPStan/Analyser/nsrt/array-search-existing.php @@ -46,7 +46,7 @@ function arraySearchStrictNarrowsToNeedle(array $arr, int $needle): void $key = array_search($needle, $arr, true); if ($key !== false) { assertType('non-empty-array', $arr); - assertType('string', $key); + assertType('(int|string)', $key); assertType('int', $arr[$key]); } else { assertType('array', $arr); @@ -54,7 +54,7 @@ function arraySearchStrictNarrowsToNeedle(array $arr, int $needle): void assertType('*ERROR*', $arr[$key]); } assertType('array', $arr); - assertType('string|false', $key); + assertType('int|string|false', $key); assertType('int|string', $arr[$key]); } diff --git a/tests/PHPStan/Analyser/nsrt/array-search-php7.php b/tests/PHPStan/Analyser/nsrt/array-search-php7.php index 5816daf659..d2ead36867 100644 --- a/tests/PHPStan/Analyser/nsrt/array-search-php7.php +++ b/tests/PHPStan/Analyser/nsrt/array-search-php7.php @@ -12,9 +12,9 @@ class Foo public function mixedAndSubtractedArray($mixed, string $string): void { if (is_array($mixed)) { - assertType('int|string|false', array_search('foo', $mixed, true)); - assertType('int|string|false', array_search('foo', $mixed)); - assertType('int|string|false', array_search($string, $mixed, true)); + assertType('(int|string|false)', array_search('foo', $mixed, true)); + assertType('(int|string|false)', array_search('foo', $mixed)); + assertType('(int|string|false)', array_search($string, $mixed, true)); } else { assertType('mixed~array', $mixed); assertType('null', array_search('foo', $mixed, true)); diff --git a/tests/PHPStan/Analyser/nsrt/array-search-php8.php b/tests/PHPStan/Analyser/nsrt/array-search-php8.php index 30b9527e10..69306009b2 100644 --- a/tests/PHPStan/Analyser/nsrt/array-search-php8.php +++ b/tests/PHPStan/Analyser/nsrt/array-search-php8.php @@ -12,9 +12,9 @@ class Foo public function mixedAndSubtractedArray($mixed, string $string): void { if (is_array($mixed)) { - assertType('int|string|false', array_search('foo', $mixed, true)); - assertType('int|string|false', array_search('foo', $mixed)); - assertType('int|string|false', array_search($string, $mixed, true)); + assertType('(int|string|false)', array_search('foo', $mixed, true)); + assertType('(int|string|false)', array_search('foo', $mixed)); + assertType('(int|string|false)', array_search($string, $mixed, true)); } else { assertType('mixed~array', $mixed); assertType('*NEVER*', array_search('foo', $mixed, true)); diff --git a/tests/PHPStan/Analyser/nsrt/array-search.php b/tests/PHPStan/Analyser/nsrt/array-search.php index 680c56844a..49cf1199ce 100644 --- a/tests/PHPStan/Analyser/nsrt/array-search.php +++ b/tests/PHPStan/Analyser/nsrt/array-search.php @@ -10,9 +10,9 @@ class Foo public function nonEmpty(array $arr, string $string): void { /** @var non-empty-array $arr */ - assertType('int|string|false', array_search('foo', $arr, true)); - assertType('int|string|false', array_search('foo', $arr)); - assertType('int|string|false', array_search($string, $arr, true)); + assertType('(int|string|false)', array_search('foo', $arr, true)); + assertType('(int|string|false)', array_search('foo', $arr)); + assertType('(int|string|false)', array_search($string, $arr, true)); } public function normalArrays(array $arr, string $string): void diff --git a/tests/PHPStan/Analyser/nsrt/bug-11928.php b/tests/PHPStan/Analyser/nsrt/bug-11928.php index 94317f690f..fdd14b31d9 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11928.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11928.php @@ -49,10 +49,10 @@ function doFooStrings($unionKeyedArray, $fourOrFive) { */ function doFooBar(array $array, array $list, array $strings) { $keys = array_keys($strings, "a", true); - assertType('list', $keys); + assertType('list<(int|string)>', $keys); $keys = array_keys($strings, "a", false); - assertType('list', $keys); + assertType('list<(int|string)>', $keys); $keys = array_keys($array, 1, true); assertType('list', $keys); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14630.php b/tests/PHPStan/Analyser/nsrt/bug-14630.php index c1c4db8c9e..58b28bae79 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14630.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14630.php @@ -20,7 +20,7 @@ function d(array $a, array $b, ?int $i): void $beforeDynamicPeriodKey = array_find_key($b, static fn ($beforeDynamicPeriodDetail): bool => $beforeDynamicPeriodDetail->getRange()->equals($splitFromPeriodRange)); } - assertType('int|string|null', $beforeDynamicPeriodKey); + assertType('(int|string|null)', $beforeDynamicPeriodKey); if ($beforeDynamicPeriodKey === null) { continue; } @@ -37,7 +37,7 @@ function arrayFindKeyNullDoesNotImplyEmptyArray(array $b): void if ($key === null) { assertType('array', $b); $key2 = array_find_key($b, static fn ($v): bool => $v->bar()); - assertType('int|string|null', $key2); + assertType('(int|string|null)', $key2); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15073.php b/tests/PHPStan/Analyser/nsrt/bug-15073.php index 63834eaf26..fef4fd0689 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15073.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15073.php @@ -6,6 +6,37 @@ use function PHPStan\Testing\assertType; +/** + * @param array-key $array_key + * @param numeric-string $numeric_string + * @param decimal-int-string $decimal_string + * @param non-decimal-int-string $non_decimal_string + */ +function foreachKey(int|string $int_or_str, int|string $array_key, string $string, string $numeric_string, string $decimal_string, string $non_decimal_string): void +{ + // `foreach` hands the key back unwidened - see the class-level doc of + // UnsafeArrayStringKeyCastingTraverser, and reportUnsafeArrayStringKeyCasting + // for the accurate variant. + foreach ([$int_or_str => $int_or_str] as $key => $value) { + assertType('array{int|string, int|string}', [$key, $value]); + } + foreach ([$array_key => $array_key] as $key => $value) { + assertType('array{(int|string), (int|string)}', [$key, $value]); + } + foreach ([$string => $string] as $key => $value) { + assertType('array{string, string}', [$key, $value]); + } + foreach ([$numeric_string => $numeric_string] as $key => $value) { + assertType('array{int|numeric-string, numeric-string}', [$key, $value]); + } + foreach ([$decimal_string => $decimal_string] as $key => $value) { + assertType('array{int, decimal-int-string}', [$key, $value]); + } + foreach ([$non_decimal_string => $non_decimal_string] as $key => $value) { + assertType('array{non-decimal-int-string, non-decimal-int-string}', [$key, $value]); + } +} + /** * @param array-key $array_key * @param numeric-string $numeric_string @@ -22,19 +53,95 @@ function readKeys(int|string $int_or_str, int|string $array_key, string $string, assertType('non-decimal-int-string', array_key_first([$non_decimal_string => null])); assertType('(int|string)', array_key_last([$string => null])); + assertType('non-empty-list<(int|string)>', array_keys([$string => null])); + assertType('(int|string)', array_keys([$string => null])[0]); +} + +/** + * @param array-key $array_key + * @param numeric-string $numeric_string + * @param decimal-int-string $decimal_string + * @param non-decimal-int-string $non_decimal_string + */ +function valuesBecomeKeys(int|string $int_or_str, int|string $array_key, string $string, string $numeric_string, string $decimal_string, string $non_decimal_string): void +{ + assertType('non-empty-array', array_flip([$int_or_str, $int_or_str])); + assertType('non-empty-array<0|1>', array_flip([$array_key, $array_key])); + assertType('non-empty-array', array_flip([$string, $string])); + assertType('non-empty-array', array_flip([$numeric_string, $numeric_string])); + assertType('non-empty-array', array_flip([$decimal_string, $decimal_string])); + assertType('non-empty-array', array_flip([$non_decimal_string, $non_decimal_string])); + + assertType('non-empty-array', array_fill_keys([$int_or_str, $int_or_str], null)); + assertType('non-empty-array', array_fill_keys([$array_key, $array_key], null)); + assertType('non-empty-array', array_fill_keys([$string, $string], null)); + assertType('non-empty-array', array_fill_keys([$numeric_string, $numeric_string], null)); + assertType('non-empty-array', array_fill_keys([$decimal_string, $decimal_string], null)); + assertType('non-empty-array', array_fill_keys([$non_decimal_string, $non_decimal_string], null)); +} + +/** + * @param list $int_or_strs + * @param list $array_keys + * @param list $strings + * @param list $numeric_strings + * @param list $decimal_strings + * @param list $non_decimal_strings + */ +function valuesBecomeKeysOfGeneralArray(array $int_or_strs, array $array_keys, array $strings, array $numeric_strings, array $decimal_strings, array $non_decimal_strings): void +{ + assertType('array>', array_flip($int_or_strs)); + assertType('array>', array_flip($array_keys)); + assertType('array>', array_flip($strings)); + assertType('array>', array_flip($numeric_strings)); + assertType('array>', array_flip($decimal_strings)); + assertType('array>', array_flip($non_decimal_strings)); + + // array_fill_keys() goes through the same key cast as array_flip() above + assertType('array', array_fill_keys($int_or_strs, null)); + assertType('array', array_fill_keys($array_keys, null)); + assertType('array', array_fill_keys($strings, null)); + assertType('array', array_fill_keys($numeric_strings, null)); + assertType('array', array_fill_keys($decimal_strings, null)); + assertType('array', array_fill_keys($non_decimal_strings, null)); +} + +/** + * @param array $a + * @param array $b + */ +function iterateGeneralArray(array $a, array $b): void +{ + foreach ($a as $key => $_) { + assertType('string', $key); + } + foreach ($b as $key => $_) { + assertType('non-decimal-int-string', $key); + } + + // The widened key stays benevolent through the `null` that array_key_first() + // adds for a possibly-empty array, so neither branch reports anything. + assertType('(int|string|null)', array_key_first($a)); + assertType('non-decimal-int-string|null', array_key_first($b)); + + $key = array_key_first($a); + if ($key !== null) { + assertType('(int|string)', $key); + } } /** - * @param non-empty-array $intOrString - * @param non-empty-array|string, int> $partlyInt + * @template T of array-key + * @param array $templateKeys + * @param array|string, int> $partlyInt */ -function keyTypesThatStopShortOfTheWidening(array $intOrString, array $partlyInt): void +function keyTypesThatStopShortOfTheWidening(array $templateKeys, array $partlyInt): void { - // int|string already covers int, so it is left alone and stays strict - assertType('int|string', array_key_first($intOrString)); + // a template key is not certainly a string, so it stays as it is + assertType('list', array_keys($templateKeys)); // int<0, max>|string covers only part of int, so the string half still widens - assertType('(int|string)', array_key_first($partlyInt)); + assertType('list<(int|string)>', array_keys($partlyInt)); } function isDecimalIntString(mixed $val): bool diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 2a2851c5a1..7d8937dda9 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -242,7 +242,7 @@ public function searchUnsealedExclusiveValue(array $arr): void { assertType("'a'", array_search('foo', $arr, true)); assertType("'b'", array_search('bar', $arr, true)); - assertType("string|false", array_search('baz', $arr, true)); + assertType("(int|string|false)", array_search('baz', $arr, true)); assertType("false", array_search('quux', $arr, true)); } @@ -269,7 +269,7 @@ public function searchUnsealedStrictTypes(array $arr): void */ public function searchUnsealedNeedleInBothSides(array $arr): void { - assertType("string|false", array_search(99, $arr, true)); + assertType("(int|string|false)", array_search(99, $arr, true)); } /** @@ -284,8 +284,8 @@ public function searchUnsealedNonStrict(array $arr): void // `'a'` is a definite hit (constant value matches needle exactly, // not optional) so `false` is excluded; the explicit-key match // then merges into the unsealed-extras' broader `string` key. - assertType("string", array_search(1, $arr, false)); - assertType("string|false", array_search(99, $arr, false)); + assertType("(int|string)", array_search(1, $arr, false)); + assertType("(int|string|false)", array_search(99, $arr, false)); } /** diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 1766ba6e54..4b8e4142a2 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -445,7 +445,7 @@ public function keysFromUnsealedWithStringKeys(array $arr): void { // Source's unsealed key type is `string`, so the result's // unsealed values are strings. - assertType("array{'a', 'b', ...}", array_keys($arr)); + assertType("array{'a', 'b', ...<(int|string)>}", array_keys($arr)); } } diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 9bd4e9c6b1..f7c8531c51 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1320,7 +1320,7 @@ public function testArraySearchExisting(): void 54, ], [ - 'Offset string|false might not exist on array.', + 'Offset int|string|false might not exist on array.', 58, ], ]);