From e62aa81dd992b6296e13dc913fe4ece5fd57f8fd Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:54:27 +0000 Subject: [PATCH 01/14] Describe `Random\Randomizer` array, bytes and integer methods with dedicated dynamic return type extensions * Add `RandomizerMethodReturnTypeExtension` covering `shuffleArray()` (via `Type::shuffleArray()`, so it is a list and keeps non-emptiness and the value type), `pickArrayKeys()` (a non-empty list of the array's own key type), `shuffleBytes()`, `getBytesFromString()` and `getInt()`. * Add `StringBytesReturnTypeHelper`, describing strings built from the bytes of another string. `StrrevFunctionReturnTypeExtension` now delegates to it, and the new `StrShuffleFunctionReturnTypeExtension` uses it so `str_shuffle()` keeps non-falsy-ness, lowercase-ness and uppercase-ness instead of only non-emptiness. * Add `RandomArrayKeysReturnTypeHelper` shared by `pickArrayKeys()` and `array_rand()`; `array_rand()` now returns a `non-empty-list` of the array's own key type instead of `array`. * Extract `RandomIntRangeHelper` out of `RandomIntFunctionReturnTypeExtension` and reuse it for `Randomizer::getInt()`. * Signature map: `Randomizer::getBytes()` returns `non-empty-string` and takes a `positive-int`, `Randomizer::nextInt()` returns `int<0, max>`, `Randomizer::getBytesFromString()` returns `non-empty-string` and takes a `non-empty-string` plus a `positive-int`, and the four final `Random\Engine\*::generate()` implementations return `non-empty-string`. * Drop the now-redundant conditional `@return` of `str_shuffle()` from `stubs/core.stub`. --- resources/functionMap_php82delta.php | 8 +- resources/functionMap_php83delta.php | 1 + .../ArrayRandFunctionReturnTypeExtension.php | 29 ++--- .../Php/RandomArrayKeysReturnTypeHelper.php | 46 ++++++++ .../RandomIntFunctionReturnTypeExtension.php | 53 ++------- src/Type/Php/RandomIntRangeHelper.php | 62 +++++++++++ .../RandomizerMethodReturnTypeExtension.php | 77 +++++++++++++ .../StrShuffleFunctionReturnTypeExtension.php | 40 +++++++ src/Type/Php/StringBytesReturnTypeHelper.php | 75 +++++++++++++ .../Php/StrrevFunctionReturnTypeExtension.php | 31 +----- stubs/core.stub | 3 - .../PHPStan/Analyser/nsrt/array-functions.php | 28 ++--- tests/PHPStan/Analyser/nsrt/bug-15256-83.php | 26 +++++ tests/PHPStan/Analyser/nsrt/bug-15256.php | 105 ++++++++++++++++++ tests/PHPStan/Analyser/nsrt/str-shuffle.php | 21 +++- .../InvalidKeyInArrayDimFetchRuleTest.php | 8 +- ...nexistentOffsetInArrayDimFetchRuleTest.php | 4 +- .../PHPStan/Rules/Functions/data/bug-9803.php | 8 +- 18 files changed, 504 insertions(+), 121 deletions(-) create mode 100644 src/Type/Php/RandomArrayKeysReturnTypeHelper.php create mode 100644 src/Type/Php/RandomIntRangeHelper.php create mode 100644 src/Type/Php/RandomizerMethodReturnTypeExtension.php create mode 100644 src/Type/Php/StrShuffleFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/StringBytesReturnTypeHelper.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15256-83.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15256.php diff --git a/resources/functionMap_php82delta.php b/resources/functionMap_php82delta.php index 033e6e716b6..afe7fb43253 100644 --- a/resources/functionMap_php82delta.php +++ b/resources/functionMap_php82delta.php @@ -24,7 +24,13 @@ 'iterator_count' => ['0|positive-int', 'iterator'=>'iterable'], 'iterator_to_array' => ['array', 'iterator'=>'iterable', 'use_keys='=>'bool'], 'str_split' => ['list', 'str'=>'string', 'split_length='=>'positive-int'], - 'Random\Randomizer::pickArrayKeys' => ['non-empty-array', 'array'=>'non-empty-array', 'num'=>'positive-int'], + 'Random\Engine\Mt19937::generate' => ['non-empty-string'], + 'Random\Engine\PcgOneseq128XslRr64::generate' => ['non-empty-string'], + 'Random\Engine\Secure::generate' => ['non-empty-string'], + 'Random\Engine\Xoshiro256StarStar::generate' => ['non-empty-string'], + 'Random\Randomizer::getBytes' => ['non-empty-string', 'length'=>'positive-int'], + 'Random\Randomizer::nextInt' => ['int<0, max>'], + 'Random\Randomizer::pickArrayKeys' => ['non-empty-list', 'array'=>'non-empty-array', 'num'=>'positive-int'], ], 'old' => [ diff --git a/resources/functionMap_php83delta.php b/resources/functionMap_php83delta.php index 6b199ec76cd..e96defd162c 100644 --- a/resources/functionMap_php83delta.php +++ b/resources/functionMap_php83delta.php @@ -24,6 +24,7 @@ 'DateInterval::createFromDateString' => ['static', 'modify'=>'string'], 'DateTime::modify' => ['static', 'modify'=>'string'], 'DateTimeImmutable::modify' => ['static', 'modify'=>'string'], + 'Random\Randomizer::getBytesFromString' => ['non-empty-string', 'string'=>'non-empty-string', 'length'=>'positive-int'], 'str_decrement' => ['non-empty-string', 'string'=>'non-empty-string'], 'str_increment' => ['non-falsy-string', 'string'=>'non-empty-string'], 'gc_status' => ['array{running:bool,protected:bool,full:bool,runs:int,collected:int,threshold:int,buffer_size:int,roots:int,application_time:float,collector_time:float,destructor_time:float,free_time:float}'], diff --git a/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php b/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php index 01018174977..9f21ac499d3 100644 --- a/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php @@ -6,21 +6,21 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; -use PHPStan\Type\ArrayType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\IntegerRangeType; -use PHPStan\Type\IntegerType; -use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -use PHPStan\Type\UnionType; use function count; #[AutowiredService] final class ArrayRandFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { + public function __construct(private RandomArrayKeysReturnTypeHelper $randomArrayKeysReturnTypeHelper) + { + } + public function isFunctionSupported(FunctionReflection $functionReflection): bool { return $functionReflection->getName() === 'array_rand'; @@ -35,34 +35,27 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $firstArgType = $scope->getType($args[0]->value); - $isInteger = $firstArgType->getIterableKeyType()->isInteger(); - $isString = $firstArgType->getIterableKeyType()->isString(); - - if ($isInteger->yes()) { - $valueType = new IntegerType(); - } elseif ($isString->yes()) { - $valueType = new StringType(); - } else { - $valueType = new UnionType([new IntegerType(), new StringType()]); - } + $keyType = $this->randomArrayKeysReturnTypeHelper->getPickedKeyType($firstArgType); if ($argsCount < 2) { - return $valueType; + return $keyType; } $secondArgType = $scope->getType($args[1]->value); $one = new ConstantIntegerType(1); if ($one->isSuperTypeOf($secondArgType)->yes()) { - return $valueType; + return $keyType; } + $keysListType = $this->randomArrayKeysReturnTypeHelper->getPickedKeysListType($firstArgType); + $bigger2 = IntegerRangeType::fromInterval(2, null); if ($bigger2->isSuperTypeOf($secondArgType)->yes()) { - return new ArrayType(new IntegerType(), $valueType); + return $keysListType; } - return TypeCombinator::union($valueType, new ArrayType(new IntegerType(), $valueType)); + return TypeCombinator::union($keyType, $keysListType); } } diff --git a/src/Type/Php/RandomArrayKeysReturnTypeHelper.php b/src/Type/Php/RandomArrayKeysReturnTypeHelper.php new file mode 100644 index 00000000000..718bfac3825 --- /dev/null +++ b/src/Type/Php/RandomArrayKeysReturnTypeHelper.php @@ -0,0 +1,46 @@ +isIterableAtLeastOnce()->no()) { + // Picking out of an empty array always throws, there's no key to describe. + return $arrayKeyType; + } + + return TypeCombinator::intersect($arrayType->getIterableKeyType(), $arrayKeyType); + } + + /** + * Picking more than one key returns them re-indexed from zero, keeping their original order. + */ + public function getPickedKeysListType(Type $arrayType): Type + { + return TypeCombinator::intersect( + new ArrayType(new IntegerType(), $this->getPickedKeyType($arrayType)), + new AccessoryArrayListType(), + new NonEmptyArrayType(), + ); + } + +} diff --git a/src/Type/Php/RandomIntFunctionReturnTypeExtension.php b/src/Type/Php/RandomIntFunctionReturnTypeExtension.php index 9e8fbac412e..f190270a938 100644 --- a/src/Type/Php/RandomIntFunctionReturnTypeExtension.php +++ b/src/Type/Php/RandomIntFunctionReturnTypeExtension.php @@ -6,22 +6,20 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; -use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\IntegerRangeType; use PHPStan\Type\Type; -use PHPStan\Type\UnionType; -use function array_map; -use function assert; use function count; use function in_array; -use function max; -use function min; #[AutowiredService] final class RandomIntFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { + public function __construct(private RandomIntRangeHelper $randomIntRangeHelper) + { + } + public function isFunctionSupported(FunctionReflection $functionReflection): bool { return in_array($functionReflection->getName(), ['random_int', 'rand', 'mt_rand'], true); @@ -38,46 +36,9 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return null; } - $minType = $scope->getType($args[0]->value)->toInteger(); - $maxType = $scope->getType($args[1]->value)->toInteger(); - - return $this->createRange($minType, $maxType); - } - - private function createRange(Type $minType, Type $maxType): Type - { - $minValues = array_map( - static function (Type $type): ?int { - if ($type instanceof IntegerRangeType) { - return $type->getMin(); - } - if ($type instanceof ConstantIntegerType) { - return $type->getValue(); - } - return null; - }, - $minType instanceof UnionType ? $minType->getTypes() : [$minType], - ); - - $maxValues = array_map( - static function (Type $type): ?int { - if ($type instanceof IntegerRangeType) { - return $type->getMax(); - } - if ($type instanceof ConstantIntegerType) { - return $type->getValue(); - } - return null; - }, - $maxType instanceof UnionType ? $maxType->getTypes() : [$maxType], - ); - - assert(count($minValues) > 0); - assert(count($maxValues) > 0); - - return IntegerRangeType::fromInterval( - in_array(null, $minValues, true) ? null : min($minValues), - in_array(null, $maxValues, true) ? null : max($maxValues), + return $this->randomIntRangeHelper->createRange( + $scope->getType($args[0]->value)->toInteger(), + $scope->getType($args[1]->value)->toInteger(), ); } diff --git a/src/Type/Php/RandomIntRangeHelper.php b/src/Type/Php/RandomIntRangeHelper.php new file mode 100644 index 00000000000..637748d4b27 --- /dev/null +++ b/src/Type/Php/RandomIntRangeHelper.php @@ -0,0 +1,62 @@ +getMin(); + } + if ($type instanceof ConstantIntegerType) { + return $type->getValue(); + } + return null; + }, + $minType instanceof UnionType ? $minType->getTypes() : [$minType], + ); + + $maxValues = array_map( + static function (Type $type): ?int { + if ($type instanceof IntegerRangeType) { + return $type->getMax(); + } + if ($type instanceof ConstantIntegerType) { + return $type->getValue(); + } + return null; + }, + $maxType instanceof UnionType ? $maxType->getTypes() : [$maxType], + ); + + assert(count($minValues) > 0); + assert(count($maxValues) > 0); + + return IntegerRangeType::fromInterval( + in_array(null, $minValues, true) ? null : min($minValues), + in_array(null, $maxValues, true) ? null : max($maxValues), + ); + } + +} diff --git a/src/Type/Php/RandomizerMethodReturnTypeExtension.php b/src/Type/Php/RandomizerMethodReturnTypeExtension.php new file mode 100644 index 00000000000..a0f10031aee --- /dev/null +++ b/src/Type/Php/RandomizerMethodReturnTypeExtension.php @@ -0,0 +1,77 @@ +getName(), [ + 'shuffleArray', + 'pickArrayKeys', + 'shuffleBytes', + 'getBytesFromString', + 'getInt', + ], true); + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + $args = $methodCall->getArgs(); + if (count($args) < 1) { + return null; + } + + $firstArgType = $scope->getType($args[0]->value); + + switch ($methodReflection->getName()) { + case 'shuffleArray': + return $firstArgType->shuffleArray(); + case 'pickArrayKeys': + // $num is validated to be between 1 and the size of the array, + // so a successful call always returns at least one key. + return $this->randomArrayKeysReturnTypeHelper->getPickedKeysListType($firstArgType); + case 'shuffleBytes': + return $this->stringBytesReturnTypeHelper->getReorderedStringType($firstArgType); + case 'getBytesFromString': + return $this->stringBytesReturnTypeHelper->getNonEmptySelectionStringType($firstArgType); + case 'getInt': + if (count($args) < 2) { + return null; + } + + return $this->randomIntRangeHelper->createRange( + $firstArgType->toInteger(), + $scope->getType($args[1]->value)->toInteger(), + ); + } + + return null; + } + +} diff --git a/src/Type/Php/StrShuffleFunctionReturnTypeExtension.php b/src/Type/Php/StrShuffleFunctionReturnTypeExtension.php new file mode 100644 index 00000000000..b807a80db05 --- /dev/null +++ b/src/Type/Php/StrShuffleFunctionReturnTypeExtension.php @@ -0,0 +1,40 @@ +getName() === 'str_shuffle'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + Scope $scope, + ): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) < 1) { + return null; + } + + return $this->stringBytesReturnTypeHelper->getReorderedStringType($scope->getType($args[0]->value)); + } + +} diff --git a/src/Type/Php/StringBytesReturnTypeHelper.php b/src/Type/Php/StringBytesReturnTypeHelper.php new file mode 100644 index 00000000000..46efdf2e437 --- /dev/null +++ b/src/Type/Php/StringBytesReturnTypeHelper.php @@ -0,0 +1,75 @@ +isNonFalsyString()->yes()) { + $accessoryTypes[] = new AccessoryNonFalsyStringType(); + } elseif ($inputType->isNonEmptyString()->yes()) { + $accessoryTypes[] = new AccessoryNonEmptyStringType(); + } + + $accessoryTypes = array_merge($accessoryTypes, $this->getCaseAccessoryTypes($inputType)); + if (count($accessoryTypes) === 0) { + return null; + } + + return TypeCombinator::intersect(new StringType(), ...$accessoryTypes); + } + + /** + * Result contains at least one byte of $inputType, each one possibly repeated. + * Non-falsy-ness is not preserved: picking a single byte of '10' can result in '0'. + */ + public function getNonEmptySelectionStringType(Type $inputType): Type + { + $accessoryTypes = array_merge( + [new AccessoryNonEmptyStringType()], + $this->getCaseAccessoryTypes($inputType), + ); + + return TypeCombinator::intersect(new StringType(), ...$accessoryTypes); + } + + /** + * @return list + */ + private function getCaseAccessoryTypes(Type $inputType): array + { + $accessoryTypes = []; + if ($inputType->isLowercaseString()->yes()) { + $accessoryTypes[] = new AccessoryLowercaseStringType(); + } + if ($inputType->isUppercaseString()->yes()) { + $accessoryTypes[] = new AccessoryUppercaseStringType(); + } + + return $accessoryTypes; + } + +} diff --git a/src/Type/Php/StrrevFunctionReturnTypeExtension.php b/src/Type/Php/StrrevFunctionReturnTypeExtension.php index 4bbc2c8fb8b..6fc3ffd2638 100644 --- a/src/Type/Php/StrrevFunctionReturnTypeExtension.php +++ b/src/Type/Php/StrrevFunctionReturnTypeExtension.php @@ -6,14 +6,8 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; -use PHPStan\Type\Accessory\AccessoryLowercaseStringType; -use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; -use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; -use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; -use PHPStan\Type\IntersectionType; -use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function count; @@ -23,6 +17,10 @@ final class StrrevFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { + public function __construct(private StringBytesReturnTypeHelper $stringBytesReturnTypeHelper) + { + } + public function isFunctionSupported(FunctionReflection $functionReflection): bool { return $functionReflection->getName() === 'strrev'; @@ -50,26 +48,7 @@ public function getTypeFromFunctionCall( return TypeCombinator::union(...$resultTypes); } - $accessoryTypes = []; - if ($inputType->isNonFalsyString()->yes()) { - $accessoryTypes[] = new AccessoryNonFalsyStringType(); - } elseif ($inputType->isNonEmptyString()->yes()) { - $accessoryTypes[] = new AccessoryNonEmptyStringType(); - } - if ($inputType->isLowercaseString()->yes()) { - $accessoryTypes[] = new AccessoryLowercaseStringType(); - } - if ($inputType->isUppercaseString()->yes()) { - $accessoryTypes[] = new AccessoryUppercaseStringType(); - } - - if (count($accessoryTypes) > 0) { - $accessoryTypes[] = new StringType(); - - return new IntersectionType($accessoryTypes); - } - - return null; + return $this->stringBytesReturnTypeHelper->getReorderedStringType($inputType); } } diff --git a/stubs/core.stub b/stubs/core.stub index beee073b9d2..a0ffdc20a28 100644 --- a/stubs/core.stub +++ b/stubs/core.stub @@ -132,9 +132,6 @@ function base64_encode(string $string) : string {} */ function bin2hex(string $string): string {} -/** - * @return ($string is non-empty-string ? non-empty-string : string) - */ function str_shuffle(string $string): string {} /** diff --git a/tests/PHPStan/Analyser/nsrt/array-functions.php b/tests/PHPStan/Analyser/nsrt/array-functions.php index dbbbe0cf76c..b5a640c8cef 100644 --- a/tests/PHPStan/Analyser/nsrt/array-functions.php +++ b/tests/PHPStan/Analyser/nsrt/array-functions.php @@ -351,19 +351,19 @@ assertType('string|null', key($generalStringKeys)); assertType('int|string|null', key($generalIntegerOrStringKeysMixedValues)); assertType('\'foo\'', $poppedFoo); -assertType('int', array_rand([1 => 1, 2 => "2"])); -assertType('string', array_rand(["a" => 1, "b" => "2"])); -assertType('int|string', array_rand(["a" => 1, 2 => "b"])); +assertType('1|2', array_rand([1 => 1, 2 => "2"])); +assertType("'a'|'b'", array_rand(["a" => 1, "b" => "2"])); +assertType("2|'a'", array_rand(["a" => 1, 2 => "b"])); assertType('int|string', array_rand([1 => 1, 2 => "b", $mixed => $mixed])); -assertType('int', array_rand([1 => 1, 2 => "b"], 1)); -assertType('string', array_rand(["a" => 1, "b" => "b"], 1)); -assertType('int|string', array_rand(["a" => 1, 2 => "b"], 1)); +assertType('1|2', array_rand([1 => 1, 2 => "b"], 1)); +assertType("'a'|'b'", array_rand(["a" => 1, "b" => "b"], 1)); +assertType("2|'a'", array_rand(["a" => 1, 2 => "b"], 1)); assertType('int|string', array_rand([1 => 1, 2 => "b", $mixed => $mixed], 1)); -assertType('array', array_rand([1 => 1, 2 => "b"], 2)); -assertType('array', array_rand(["a" => 1, "b" => "b"], 2)); -assertType('array', array_rand(["a" => 1, 2 => "b"], 2)); -assertType('array', array_rand([1 => 1, 2 => "2", $mixed => $mixed], 2)); -assertType('array|int', array_rand([1 => 1, 2 => "b"], $mixed)); -assertType('array|string', array_rand(["a" => 1, "b" => "b"], $mixed)); -assertType('array|int|string', array_rand(["a" => 1, 2 => "b"], $mixed)); -assertType('array|int|string', array_rand([1 => 1, 2 => "b", $mixed => $mixed], $mixed)); +assertType('non-empty-list<1|2>', array_rand([1 => 1, 2 => "b"], 2)); +assertType("non-empty-list<'a'|'b'>", array_rand(["a" => 1, "b" => "b"], 2)); +assertType("non-empty-list<2|'a'>", array_rand(["a" => 1, 2 => "b"], 2)); +assertType('non-empty-list', array_rand([1 => 1, 2 => "2", $mixed => $mixed], 2)); +assertType('1|2|non-empty-list<1|2>', array_rand([1 => 1, 2 => "b"], $mixed)); +assertType("'a'|'b'|non-empty-list<'a'|'b'>", array_rand(["a" => 1, "b" => "b"], $mixed)); +assertType("2|'a'|non-empty-list<2|'a'>", array_rand(["a" => 1, 2 => "b"], $mixed)); +assertType('int|non-empty-list|string', array_rand([1 => 1, 2 => "b", $mixed => $mixed], $mixed)); diff --git a/tests/PHPStan/Analyser/nsrt/bug-15256-83.php b/tests/PHPStan/Analyser/nsrt/bug-15256-83.php new file mode 100644 index 00000000000..c72de0c37c2 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15256-83.php @@ -0,0 +1,26 @@ += 8.3 + +declare(strict_types = 1); + +namespace Bug15256Php83; + +use Random\Randomizer; +use function PHPStan\Testing\assertType; + +/** + * @param non-empty-string $nonEmptyString + * @param lowercase-string $lowercaseString + * @param uppercase-string $uppercaseString + */ +function getBytesFromString( + Randomizer $randomizer, + string $nonEmptyString, + string $lowercaseString, + string $uppercaseString +): void +{ + assertType('non-empty-string', $randomizer->getBytesFromString($nonEmptyString, 5)); + assertType('lowercase-string&non-empty-string', $randomizer->getBytesFromString($lowercaseString, 5)); + assertType('non-empty-string&uppercase-string', $randomizer->getBytesFromString($uppercaseString, 5)); + assertType('lowercase-string&non-empty-string', $randomizer->getBytesFromString('abc', 5)); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-15256.php b/tests/PHPStan/Analyser/nsrt/bug-15256.php new file mode 100644 index 00000000000..ea837d10159 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15256.php @@ -0,0 +1,105 @@ + + */ + public static function shuffle(array $array): array + { + $randomizer = new Randomizer(); + assertType('list', $randomizer->shuffleArray($array)); + + return $randomizer->shuffleArray($array); + } + + /** + * @template TKey of array-key + * @param non-empty-array $array + * @return non-empty-list + */ + public static function pick3ArrayKeys(array $array): array + { + $randomizer = new Randomizer(); + assertType('non-empty-list', $randomizer->pickArrayKeys($array, 3)); + + return $randomizer->pickArrayKeys($array, 3); + } + +} + +/** + * @param non-empty-array $nonEmptyArray + * @param list $list + * @param array $mixedKeys + */ +function arrayMethods( + Randomizer $randomizer, + array $nonEmptyArray, + array $list, + array $mixedKeys +): void +{ + assertType('non-empty-list', $randomizer->shuffleArray($nonEmptyArray)); + assertType('list', $randomizer->shuffleArray($list)); + assertType('non-empty-list<1|2>', $randomizer->shuffleArray(['a' => 1, 'b' => 2])); + assertType('array{}', $randomizer->shuffleArray([])); + assertType('list', $randomizer->shuffleArray($mixedKeys)); + + assertType('non-empty-list', $randomizer->pickArrayKeys($nonEmptyArray, 2)); + assertType('non-empty-list>', $randomizer->pickArrayKeys($list, 2)); + assertType('non-empty-list', $randomizer->pickArrayKeys($mixedKeys, 2)); + assertType("non-empty-list<'a'|'b'>", $randomizer->pickArrayKeys(['a' => 1, 'b' => 2], 2)); +} + +/** + * @param non-empty-string $nonEmptyString + * @param non-falsy-string $nonFalsyString + * @param lowercase-string $lowercaseString + * @param uppercase-string $uppercaseString + */ +function stringMethods( + Randomizer $randomizer, + string $string, + string $nonEmptyString, + string $nonFalsyString, + string $lowercaseString, + string $uppercaseString +): void +{ + assertType('string', $randomizer->shuffleBytes($string)); + assertType('non-empty-string', $randomizer->shuffleBytes($nonEmptyString)); + assertType('non-falsy-string', $randomizer->shuffleBytes($nonFalsyString)); + assertType('lowercase-string', $randomizer->shuffleBytes($lowercaseString)); + assertType('uppercase-string', $randomizer->shuffleBytes($uppercaseString)); + assertType('lowercase-string&non-falsy-string', $randomizer->shuffleBytes('abc')); + + assertType('non-empty-string', $randomizer->getBytes(5)); +} + +function numberMethods(Randomizer $randomizer, int $int): void +{ + assertType('int<1, 10>', $randomizer->getInt(1, 10)); + assertType('int<0, max>', $randomizer->getInt(0, $int)); + assertType('int<0, max>', $randomizer->nextInt()); +} + +function engines(): void +{ + assertType('non-empty-string', (new Mt19937())->generate()); + assertType('non-empty-string', (new PcgOneseq128XslRr64())->generate()); + assertType('non-empty-string', (new Secure())->generate()); + assertType('non-empty-string', (new Xoshiro256StarStar())->generate()); +} diff --git a/tests/PHPStan/Analyser/nsrt/str-shuffle.php b/tests/PHPStan/Analyser/nsrt/str-shuffle.php index 37aa7685252..1f5818697ea 100644 --- a/tests/PHPStan/Analyser/nsrt/str-shuffle.php +++ b/tests/PHPStan/Analyser/nsrt/str-shuffle.php @@ -9,11 +9,26 @@ class X { /** * @param non-empty-string $nonES + * @param non-falsy-string $nonFalsyString + * @param numeric-string $numericString + * @param lowercase-string $lowercaseString + * @param uppercase-string $uppercaseString */ - function doFoo(string $s, $nonES):void { - assertType('non-empty-string', str_shuffle(self::ABC)); - assertType('non-empty-string', str_shuffle('abc')); + function doFoo( + string $s, + $nonES, + string $nonFalsyString, + string $numericString, + string $lowercaseString, + string $uppercaseString + ):void { + assertType('lowercase-string&non-falsy-string', str_shuffle(self::ABC)); + assertType('lowercase-string&non-falsy-string', str_shuffle('abc')); assertType('string', str_shuffle($s)); assertType('non-empty-string', str_shuffle($nonES)); + assertType('non-falsy-string', str_shuffle($nonFalsyString)); + assertType('non-empty-string', str_shuffle($numericString)); + assertType('lowercase-string', str_shuffle($lowercaseString)); + assertType('uppercase-string', str_shuffle($uppercaseString)); } } diff --git a/tests/PHPStan/Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php index 45911ff6d6a..94a97036425 100644 --- a/tests/PHPStan/Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php @@ -157,19 +157,19 @@ public function testBug12981(): void { $this->analyse([__DIR__ . '/data/bug-12981.php'], [ [ - 'Invalid array key type array.', + 'Invalid array key type list<(int|string)>.', 31, ], [ - 'Invalid array key type array.', + 'Invalid array key type list<(int|string)>.', 33, ], [ - 'Possibly invalid array key type array|int|string.', + 'Possibly invalid array key type int|list<(int|string)>|string.', 39, ], [ - 'Possibly invalid array key type array|int|string.', + 'Possibly invalid array key type int|list<(int|string)>|string.', 41, ], ]); diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 7e495b8a32a..2fb885d3652 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -982,11 +982,11 @@ public function testBug12981(): void $this->analyse([__DIR__ . '/data/bug-12981.php'], [ [ - 'Offset array|int|string might not exist on non-empty-array.', + 'Offset int|non-empty-list<(int|string)>|string might not exist on non-empty-array.', 39, ], [ - 'Offset array|int|string might not exist on non-empty-array.', + 'Offset int|non-empty-list<(int|string)>|string might not exist on non-empty-array.', 41, ], ]); diff --git a/tests/PHPStan/Rules/Functions/data/bug-9803.php b/tests/PHPStan/Rules/Functions/data/bug-9803.php index 6e02f6ea991..d7f6a6a8d20 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-9803.php +++ b/tests/PHPStan/Rules/Functions/data/bug-9803.php @@ -13,16 +13,16 @@ function doFoo() { $keys = array(); if ($random == 1) { $keys = array(array_rand($array)); - assertType('array{int}', $keys); + assertType('array{0|1|2|3|4|5|6|7|8|9}', $keys); } else { $keys = array_rand($array, $random); - assertType('array', $keys); + assertType('non-empty-list<0|1|2|3|4|5|6|7|8|9>', $keys); } - assertType('array', $keys); + assertType('non-empty-list<0|1|2|3|4|5|6|7|8|9>', $keys); $theKeys = array_keys($keys); - assertType('list', $theKeys); + assertType('non-empty-list>', $theKeys); } From e6c858507092399a5e562bcf9ba31a0f16d57ab0 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 17 Sep 2026 20:11:21 +0000 Subject: [PATCH 02/14] Leave `Randomizer::getBytesFromString()` to the function map Preserving the lowercase/uppercase-ness of the source alphabet was the only thing the extension added on top of the `non-empty-string` the function map already describes, and it needed a second, subtly different flavour of the byte-level string helper: unlike a reordering, a selection of bytes does not keep non-falsy-ness ('10' can yield '0'). Co-Authored-By: Claude Opus 5 --- .../RandomizerMethodReturnTypeExtension.php | 3 -- src/Type/Php/StringBytesReturnTypeHelper.php | 41 ++++--------------- tests/PHPStan/Analyser/nsrt/bug-15256-83.php | 9 ++-- 3 files changed, 10 insertions(+), 43 deletions(-) diff --git a/src/Type/Php/RandomizerMethodReturnTypeExtension.php b/src/Type/Php/RandomizerMethodReturnTypeExtension.php index a0f10031aee..2ff5a3dcec7 100644 --- a/src/Type/Php/RandomizerMethodReturnTypeExtension.php +++ b/src/Type/Php/RandomizerMethodReturnTypeExtension.php @@ -35,7 +35,6 @@ public function isMethodSupported(MethodReflection $methodReflection): bool 'shuffleArray', 'pickArrayKeys', 'shuffleBytes', - 'getBytesFromString', 'getInt', ], true); } @@ -58,8 +57,6 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return $this->randomArrayKeysReturnTypeHelper->getPickedKeysListType($firstArgType); case 'shuffleBytes': return $this->stringBytesReturnTypeHelper->getReorderedStringType($firstArgType); - case 'getBytesFromString': - return $this->stringBytesReturnTypeHelper->getNonEmptySelectionStringType($firstArgType); case 'getInt': if (count($args) < 2) { return null; diff --git a/src/Type/Php/StringBytesReturnTypeHelper.php b/src/Type/Php/StringBytesReturnTypeHelper.php index 46efdf2e437..7ffc2b043e9 100644 --- a/src/Type/Php/StringBytesReturnTypeHelper.php +++ b/src/Type/Php/StringBytesReturnTypeHelper.php @@ -10,13 +10,11 @@ use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -use function array_merge; use function count; /** - * Describes strings that are built out of the bytes of another string, either by - * reordering all of them (strrev(), str_shuffle(), Random\Randomizer::shuffleBytes()) - * or by picking some of them (Random\Randomizer::getBytesFromString()). + * Describes strings that are built by reordering the bytes of another string, + * as done by strrev(), str_shuffle() and Random\Randomizer::shuffleBytes(). */ #[AutowiredService] final class StringBytesReturnTypeHelper @@ -33,35 +31,6 @@ public function getReorderedStringType(Type $inputType): ?Type } elseif ($inputType->isNonEmptyString()->yes()) { $accessoryTypes[] = new AccessoryNonEmptyStringType(); } - - $accessoryTypes = array_merge($accessoryTypes, $this->getCaseAccessoryTypes($inputType)); - if (count($accessoryTypes) === 0) { - return null; - } - - return TypeCombinator::intersect(new StringType(), ...$accessoryTypes); - } - - /** - * Result contains at least one byte of $inputType, each one possibly repeated. - * Non-falsy-ness is not preserved: picking a single byte of '10' can result in '0'. - */ - public function getNonEmptySelectionStringType(Type $inputType): Type - { - $accessoryTypes = array_merge( - [new AccessoryNonEmptyStringType()], - $this->getCaseAccessoryTypes($inputType), - ); - - return TypeCombinator::intersect(new StringType(), ...$accessoryTypes); - } - - /** - * @return list - */ - private function getCaseAccessoryTypes(Type $inputType): array - { - $accessoryTypes = []; if ($inputType->isLowercaseString()->yes()) { $accessoryTypes[] = new AccessoryLowercaseStringType(); } @@ -69,7 +38,11 @@ private function getCaseAccessoryTypes(Type $inputType): array $accessoryTypes[] = new AccessoryUppercaseStringType(); } - return $accessoryTypes; + if (count($accessoryTypes) === 0) { + return null; + } + + return TypeCombinator::intersect(new StringType(), ...$accessoryTypes); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15256-83.php b/tests/PHPStan/Analyser/nsrt/bug-15256-83.php index c72de0c37c2..57ea04f3f70 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15256-83.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15256-83.php @@ -10,17 +10,14 @@ /** * @param non-empty-string $nonEmptyString * @param lowercase-string $lowercaseString - * @param uppercase-string $uppercaseString */ function getBytesFromString( Randomizer $randomizer, string $nonEmptyString, - string $lowercaseString, - string $uppercaseString + string $lowercaseString ): void { assertType('non-empty-string', $randomizer->getBytesFromString($nonEmptyString, 5)); - assertType('lowercase-string&non-empty-string', $randomizer->getBytesFromString($lowercaseString, 5)); - assertType('non-empty-string&uppercase-string', $randomizer->getBytesFromString($uppercaseString, 5)); - assertType('lowercase-string&non-empty-string', $randomizer->getBytesFromString('abc', 5)); + assertType('non-empty-string', $randomizer->getBytesFromString($lowercaseString, 5)); + assertType('non-empty-string', $randomizer->getBytesFromString('abc', 5)); } From 2191d73dc6535676a8712313a9203a42e8533a11 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 17 Sep 2026 20:12:16 +0000 Subject: [PATCH 03/14] Describe Randomizer methods through the extensions of the functions they mirror Each `Randomizer` method handled here has a global-function counterpart PHPStan already describes, so instead of extracting the shared logic into standalone helper services, ask those extensions directly: * `pickArrayKeys()` asks `ArrayRandFunctionReturnTypeExtension` for the keys picked out of the array, always as a list (`$num` is validated to be at least 1, so unlike `array_rand()` it never returns a bare key), * `shuffleBytes()` asks `StrShuffleFunctionReturnTypeExtension` for the string made of the same bytes in another order - which is also what `strrev()` does outside of the constant-string case, so that extension asks for it too, * `getInt()` asks `RandomIntFunctionReturnTypeExtension` for the range, * `shuffleArray()` keeps using the `Type::shuffleArray()` primitive `shuffle()` is built on. Co-Authored-By: Claude Opus 5 --- .../ArrayRandFunctionReturnTypeExtension.php | 37 +++++++++-- .../Php/RandomArrayKeysReturnTypeHelper.php | 46 -------------- .../RandomIntFunctionReturnTypeExtension.php | 57 ++++++++++++++--- src/Type/Php/RandomIntRangeHelper.php | 62 ------------------- .../RandomizerMethodReturnTypeExtension.php | 19 +++--- .../StrShuffleFunctionReturnTypeExtension.php | 38 ++++++++++-- src/Type/Php/StringBytesReturnTypeHelper.php | 48 -------------- .../Php/StrrevFunctionReturnTypeExtension.php | 5 +- 8 files changed, 129 insertions(+), 183 deletions(-) delete mode 100644 src/Type/Php/RandomArrayKeysReturnTypeHelper.php delete mode 100644 src/Type/Php/RandomIntRangeHelper.php delete mode 100644 src/Type/Php/StringBytesReturnTypeHelper.php diff --git a/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php b/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php index 9f21ac499d3..631dc551ff2 100644 --- a/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php @@ -6,21 +6,23 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\NonEmptyArrayType; +use PHPStan\Type\ArrayType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\IntegerRangeType; +use PHPStan\Type\IntegerType; +use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\UnionType; use function count; #[AutowiredService] final class ArrayRandFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - public function __construct(private RandomArrayKeysReturnTypeHelper $randomArrayKeysReturnTypeHelper) - { - } - public function isFunctionSupported(FunctionReflection $functionReflection): bool { return $functionReflection->getName() === 'array_rand'; @@ -35,7 +37,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $firstArgType = $scope->getType($args[0]->value); - $keyType = $this->randomArrayKeysReturnTypeHelper->getPickedKeyType($firstArgType); + $keyType = $this->getPickedKeyType($firstArgType); if ($argsCount < 2) { return $keyType; @@ -48,7 +50,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return $keyType; } - $keysListType = $this->randomArrayKeysReturnTypeHelper->getPickedKeysListType($firstArgType); + $keysListType = $this->getPickedKeysListType($firstArgType); $bigger2 = IntegerRangeType::fromInterval(2, null); if ($bigger2->isSuperTypeOf($secondArgType)->yes()) { @@ -58,4 +60,27 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return TypeCombinator::union($keyType, $keysListType); } + public function getPickedKeyType(Type $arrayType): Type + { + $arrayKeyType = new UnionType([new IntegerType(), new StringType()]); + if ($arrayType->isIterableAtLeastOnce()->no()) { + // Picking out of an empty array always throws, there's no key to describe. + return $arrayKeyType; + } + + return TypeCombinator::intersect($arrayType->getIterableKeyType(), $arrayKeyType); + } + + /** + * Picking more than one key returns them re-indexed from zero, keeping their original order. + */ + public function getPickedKeysListType(Type $arrayType): Type + { + return TypeCombinator::intersect( + new ArrayType(new IntegerType(), $this->getPickedKeyType($arrayType)), + new AccessoryArrayListType(), + new NonEmptyArrayType(), + ); + } + } diff --git a/src/Type/Php/RandomArrayKeysReturnTypeHelper.php b/src/Type/Php/RandomArrayKeysReturnTypeHelper.php deleted file mode 100644 index 718bfac3825..00000000000 --- a/src/Type/Php/RandomArrayKeysReturnTypeHelper.php +++ /dev/null @@ -1,46 +0,0 @@ -isIterableAtLeastOnce()->no()) { - // Picking out of an empty array always throws, there's no key to describe. - return $arrayKeyType; - } - - return TypeCombinator::intersect($arrayType->getIterableKeyType(), $arrayKeyType); - } - - /** - * Picking more than one key returns them re-indexed from zero, keeping their original order. - */ - public function getPickedKeysListType(Type $arrayType): Type - { - return TypeCombinator::intersect( - new ArrayType(new IntegerType(), $this->getPickedKeyType($arrayType)), - new AccessoryArrayListType(), - new NonEmptyArrayType(), - ); - } - -} diff --git a/src/Type/Php/RandomIntFunctionReturnTypeExtension.php b/src/Type/Php/RandomIntFunctionReturnTypeExtension.php index f190270a938..0f854620ff6 100644 --- a/src/Type/Php/RandomIntFunctionReturnTypeExtension.php +++ b/src/Type/Php/RandomIntFunctionReturnTypeExtension.php @@ -6,20 +6,22 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\IntegerRangeType; use PHPStan\Type\Type; +use PHPStan\Type\UnionType; +use function array_map; +use function assert; use function count; use function in_array; +use function max; +use function min; #[AutowiredService] final class RandomIntFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - public function __construct(private RandomIntRangeHelper $randomIntRangeHelper) - { - } - public function isFunctionSupported(FunctionReflection $functionReflection): bool { return in_array($functionReflection->getName(), ['random_int', 'rand', 'mt_rand'], true); @@ -36,9 +38,50 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return null; } - return $this->randomIntRangeHelper->createRange( - $scope->getType($args[0]->value)->toInteger(), - $scope->getType($args[1]->value)->toInteger(), + $minType = $scope->getType($args[0]->value)->toInteger(); + $maxType = $scope->getType($args[1]->value)->toInteger(); + + return $this->createRange($minType, $maxType); + } + + /** + * Widest possible range of integers a random number generator bounded by + * $minType and $maxType can produce. + */ + public function createRange(Type $minType, Type $maxType): Type + { + $minValues = array_map( + static function (Type $type): ?int { + if ($type instanceof IntegerRangeType) { + return $type->getMin(); + } + if ($type instanceof ConstantIntegerType) { + return $type->getValue(); + } + return null; + }, + $minType instanceof UnionType ? $minType->getTypes() : [$minType], + ); + + $maxValues = array_map( + static function (Type $type): ?int { + if ($type instanceof IntegerRangeType) { + return $type->getMax(); + } + if ($type instanceof ConstantIntegerType) { + return $type->getValue(); + } + return null; + }, + $maxType instanceof UnionType ? $maxType->getTypes() : [$maxType], + ); + + assert(count($minValues) > 0); + assert(count($maxValues) > 0); + + return IntegerRangeType::fromInterval( + in_array(null, $minValues, true) ? null : min($minValues), + in_array(null, $maxValues, true) ? null : max($maxValues), ); } diff --git a/src/Type/Php/RandomIntRangeHelper.php b/src/Type/Php/RandomIntRangeHelper.php deleted file mode 100644 index 637748d4b27..00000000000 --- a/src/Type/Php/RandomIntRangeHelper.php +++ /dev/null @@ -1,62 +0,0 @@ -getMin(); - } - if ($type instanceof ConstantIntegerType) { - return $type->getValue(); - } - return null; - }, - $minType instanceof UnionType ? $minType->getTypes() : [$minType], - ); - - $maxValues = array_map( - static function (Type $type): ?int { - if ($type instanceof IntegerRangeType) { - return $type->getMax(); - } - if ($type instanceof ConstantIntegerType) { - return $type->getValue(); - } - return null; - }, - $maxType instanceof UnionType ? $maxType->getTypes() : [$maxType], - ); - - assert(count($minValues) > 0); - assert(count($maxValues) > 0); - - return IntegerRangeType::fromInterval( - in_array(null, $minValues, true) ? null : min($minValues), - in_array(null, $maxValues, true) ? null : max($maxValues), - ); - } - -} diff --git a/src/Type/Php/RandomizerMethodReturnTypeExtension.php b/src/Type/Php/RandomizerMethodReturnTypeExtension.php index 2ff5a3dcec7..aea9d3d10a6 100644 --- a/src/Type/Php/RandomizerMethodReturnTypeExtension.php +++ b/src/Type/Php/RandomizerMethodReturnTypeExtension.php @@ -12,14 +12,19 @@ use function count; use function in_array; +/** + * Randomizer methods mirror global functions PHPStan already describes: + * shuffleArray() is shuffle(), pickArrayKeys() is array_rand(), + * shuffleBytes() is str_shuffle() and getInt() is random_int(). + */ #[AutowiredService] final class RandomizerMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension { public function __construct( - private RandomArrayKeysReturnTypeHelper $randomArrayKeysReturnTypeHelper, - private StringBytesReturnTypeHelper $stringBytesReturnTypeHelper, - private RandomIntRangeHelper $randomIntRangeHelper, + private ArrayRandFunctionReturnTypeExtension $arrayRandExtension, + private StrShuffleFunctionReturnTypeExtension $strShuffleExtension, + private RandomIntFunctionReturnTypeExtension $randomIntExtension, ) { } @@ -53,16 +58,16 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return $firstArgType->shuffleArray(); case 'pickArrayKeys': // $num is validated to be between 1 and the size of the array, - // so a successful call always returns at least one key. - return $this->randomArrayKeysReturnTypeHelper->getPickedKeysListType($firstArgType); + // so unlike array_rand() a successful call always returns a list. + return $this->arrayRandExtension->getPickedKeysListType($firstArgType); case 'shuffleBytes': - return $this->stringBytesReturnTypeHelper->getReorderedStringType($firstArgType); + return $this->strShuffleExtension->getShuffledStringType($firstArgType); case 'getInt': if (count($args) < 2) { return null; } - return $this->randomIntRangeHelper->createRange( + return $this->randomIntExtension->createRange( $firstArgType->toInteger(), $scope->getType($args[1]->value)->toInteger(), ); diff --git a/src/Type/Php/StrShuffleFunctionReturnTypeExtension.php b/src/Type/Php/StrShuffleFunctionReturnTypeExtension.php index b807a80db05..61ff32e06ec 100644 --- a/src/Type/Php/StrShuffleFunctionReturnTypeExtension.php +++ b/src/Type/Php/StrShuffleFunctionReturnTypeExtension.php @@ -6,18 +6,20 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Type\Accessory\AccessoryLowercaseStringType; +use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; +use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; +use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; +use PHPStan\Type\StringType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function count; #[AutowiredService] final class StrShuffleFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - public function __construct(private StringBytesReturnTypeHelper $stringBytesReturnTypeHelper) - { - } - public function isFunctionSupported(FunctionReflection $functionReflection): bool { return $functionReflection->getName() === 'str_shuffle'; @@ -34,7 +36,33 @@ public function getTypeFromFunctionCall( return null; } - return $this->stringBytesReturnTypeHelper->getReorderedStringType($scope->getType($args[0]->value)); + return $this->getShuffledStringType($scope->getType($args[0]->value)); + } + + /** + * Type of a string containing every byte of $inputType exactly once, + * as produced by str_shuffle(), strrev() and Random\Randomizer::shuffleBytes(). + */ + public function getShuffledStringType(Type $inputType): ?Type + { + $accessoryTypes = []; + if ($inputType->isNonFalsyString()->yes()) { + $accessoryTypes[] = new AccessoryNonFalsyStringType(); + } elseif ($inputType->isNonEmptyString()->yes()) { + $accessoryTypes[] = new AccessoryNonEmptyStringType(); + } + if ($inputType->isLowercaseString()->yes()) { + $accessoryTypes[] = new AccessoryLowercaseStringType(); + } + if ($inputType->isUppercaseString()->yes()) { + $accessoryTypes[] = new AccessoryUppercaseStringType(); + } + + if (count($accessoryTypes) === 0) { + return null; + } + + return TypeCombinator::intersect(new StringType(), ...$accessoryTypes); } } diff --git a/src/Type/Php/StringBytesReturnTypeHelper.php b/src/Type/Php/StringBytesReturnTypeHelper.php deleted file mode 100644 index 7ffc2b043e9..00000000000 --- a/src/Type/Php/StringBytesReturnTypeHelper.php +++ /dev/null @@ -1,48 +0,0 @@ -isNonFalsyString()->yes()) { - $accessoryTypes[] = new AccessoryNonFalsyStringType(); - } elseif ($inputType->isNonEmptyString()->yes()) { - $accessoryTypes[] = new AccessoryNonEmptyStringType(); - } - if ($inputType->isLowercaseString()->yes()) { - $accessoryTypes[] = new AccessoryLowercaseStringType(); - } - if ($inputType->isUppercaseString()->yes()) { - $accessoryTypes[] = new AccessoryUppercaseStringType(); - } - - if (count($accessoryTypes) === 0) { - return null; - } - - return TypeCombinator::intersect(new StringType(), ...$accessoryTypes); - } - -} diff --git a/src/Type/Php/StrrevFunctionReturnTypeExtension.php b/src/Type/Php/StrrevFunctionReturnTypeExtension.php index 6fc3ffd2638..0f641370da2 100644 --- a/src/Type/Php/StrrevFunctionReturnTypeExtension.php +++ b/src/Type/Php/StrrevFunctionReturnTypeExtension.php @@ -17,7 +17,7 @@ final class StrrevFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - public function __construct(private StringBytesReturnTypeHelper $stringBytesReturnTypeHelper) + public function __construct(private StrShuffleFunctionReturnTypeExtension $strShuffleExtension) { } @@ -48,7 +48,8 @@ public function getTypeFromFunctionCall( return TypeCombinator::union(...$resultTypes); } - return $this->stringBytesReturnTypeHelper->getReorderedStringType($inputType); + // Reversing a string reorders its bytes, just like shuffling it. + return $this->strShuffleExtension->getShuffledStringType($inputType); } } From 091c272362826ca38244e1aee47e67bd0c34955d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 17 Sep 2026 20:27:21 +0000 Subject: [PATCH 04/14] Ask the scope for the type of the function calls Randomizer methods mirror Instead of injecting the mirrored functions' dynamic return type extensions and calling into them, build the equivalent FuncCall and hand it to Scope::getType(). The helper methods that were made public for the previous approach go back to being private. Co-Authored-By: Claude Opus 5 --- .../ArrayRandFunctionReturnTypeExtension.php | 4 +- .../RandomIntFunctionReturnTypeExtension.php | 6 +-- .../RandomizerMethodReturnTypeExtension.php | 50 ++++++++++++------- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php b/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php index 631dc551ff2..b01c2a40022 100644 --- a/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayRandFunctionReturnTypeExtension.php @@ -60,7 +60,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return TypeCombinator::union($keyType, $keysListType); } - public function getPickedKeyType(Type $arrayType): Type + private function getPickedKeyType(Type $arrayType): Type { $arrayKeyType = new UnionType([new IntegerType(), new StringType()]); if ($arrayType->isIterableAtLeastOnce()->no()) { @@ -74,7 +74,7 @@ public function getPickedKeyType(Type $arrayType): Type /** * Picking more than one key returns them re-indexed from zero, keeping their original order. */ - public function getPickedKeysListType(Type $arrayType): Type + private function getPickedKeysListType(Type $arrayType): Type { return TypeCombinator::intersect( new ArrayType(new IntegerType(), $this->getPickedKeyType($arrayType)), diff --git a/src/Type/Php/RandomIntFunctionReturnTypeExtension.php b/src/Type/Php/RandomIntFunctionReturnTypeExtension.php index 0f854620ff6..9e8fbac412e 100644 --- a/src/Type/Php/RandomIntFunctionReturnTypeExtension.php +++ b/src/Type/Php/RandomIntFunctionReturnTypeExtension.php @@ -44,11 +44,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return $this->createRange($minType, $maxType); } - /** - * Widest possible range of integers a random number generator bounded by - * $minType and $maxType can produce. - */ - public function createRange(Type $minType, Type $maxType): Type + private function createRange(Type $minType, Type $maxType): Type { $minValues = array_map( static function (Type $type): ?int { diff --git a/src/Type/Php/RandomizerMethodReturnTypeExtension.php b/src/Type/Php/RandomizerMethodReturnTypeExtension.php index aea9d3d10a6..8c6e1fa97de 100644 --- a/src/Type/Php/RandomizerMethodReturnTypeExtension.php +++ b/src/Type/Php/RandomizerMethodReturnTypeExtension.php @@ -2,13 +2,19 @@ namespace PHPStan\Type\Php; +use PhpParser\Node\Arg; +use PhpParser\Node\Expr; +use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Scalar\Int_; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\Type; use Random\Randomizer; +use function array_map; use function count; use function in_array; @@ -21,14 +27,6 @@ final class RandomizerMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension { - public function __construct( - private ArrayRandFunctionReturnTypeExtension $arrayRandExtension, - private StrShuffleFunctionReturnTypeExtension $strShuffleExtension, - private RandomIntFunctionReturnTypeExtension $randomIntExtension, - ) - { - } - public function getClass(): string { return Randomizer::class; @@ -51,29 +49,43 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } - $firstArgType = $scope->getType($args[0]->value); - switch ($methodReflection->getName()) { case 'shuffleArray': - return $firstArgType->shuffleArray(); + return $scope->getType($args[0]->value)->shuffleArray(); case 'pickArrayKeys': - // $num is validated to be between 1 and the size of the array, - // so unlike array_rand() a successful call always returns a list. - return $this->arrayRandExtension->getPickedKeysListType($firstArgType); + // $num is validated to be between 1 and the size of the array, so unlike + // array_rand() a successful call always returns a list of keys - which is + // what array_rand() returns when asked for more than one key. + return $scope->getType($this->createFuncCall('array_rand', [ + $args[0]->value, + new Int_(2), + ])); case 'shuffleBytes': - return $this->strShuffleExtension->getShuffledStringType($firstArgType); + return $scope->getType($this->createFuncCall('str_shuffle', [$args[0]->value])); case 'getInt': if (count($args) < 2) { return null; } - return $this->randomIntExtension->createRange( - $firstArgType->toInteger(), - $scope->getType($args[1]->value)->toInteger(), - ); + return $scope->getType($this->createFuncCall('random_int', [ + $args[0]->value, + $args[1]->value, + ])); } return null; } + /** + * @param non-empty-string $functionName + * @param list $argValues + */ + private function createFuncCall(string $functionName, array $argValues): FuncCall + { + return new FuncCall( + new FullyQualified($functionName), + array_map(static fn (Expr $argValue): Arg => new Arg($argValue), $argValues), + ); + } + } From c5e2b1c9475328f44f0d0f5bf9651c4414d49f40 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 17 Sep 2026 20:30:03 +0000 Subject: [PATCH 05/14] Leave strrev() alone, keep the accessory logic inside each extension str_shuffle() and strrev() both reorder the bytes of their argument, but sharing that through an injected extension is not worth the coupling - the duplication stays local to each extension instead. Co-Authored-By: Claude Opus 5 --- .../StrShuffleFunctionReturnTypeExtension.php | 22 ++++++------- .../Php/StrrevFunctionReturnTypeExtension.php | 32 +++++++++++++++---- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/Type/Php/StrShuffleFunctionReturnTypeExtension.php b/src/Type/Php/StrShuffleFunctionReturnTypeExtension.php index 61ff32e06ec..f6427fa4f42 100644 --- a/src/Type/Php/StrShuffleFunctionReturnTypeExtension.php +++ b/src/Type/Php/StrShuffleFunctionReturnTypeExtension.php @@ -11,9 +11,9 @@ use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; +use PHPStan\Type\IntersectionType; use PHPStan\Type\StringType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; use function count; #[AutowiredService] @@ -36,15 +36,9 @@ public function getTypeFromFunctionCall( return null; } - return $this->getShuffledStringType($scope->getType($args[0]->value)); - } - - /** - * Type of a string containing every byte of $inputType exactly once, - * as produced by str_shuffle(), strrev() and Random\Randomizer::shuffleBytes(). - */ - public function getShuffledStringType(Type $inputType): ?Type - { + // The result contains every byte of the input exactly once, + // so it keeps its emptiness and its casing. + $inputType = $scope->getType($args[0]->value); $accessoryTypes = []; if ($inputType->isNonFalsyString()->yes()) { $accessoryTypes[] = new AccessoryNonFalsyStringType(); @@ -58,11 +52,13 @@ public function getShuffledStringType(Type $inputType): ?Type $accessoryTypes[] = new AccessoryUppercaseStringType(); } - if (count($accessoryTypes) === 0) { - return null; + if (count($accessoryTypes) > 0) { + $accessoryTypes[] = new StringType(); + + return new IntersectionType($accessoryTypes); } - return TypeCombinator::intersect(new StringType(), ...$accessoryTypes); + return null; } } diff --git a/src/Type/Php/StrrevFunctionReturnTypeExtension.php b/src/Type/Php/StrrevFunctionReturnTypeExtension.php index 0f641370da2..4bbc2c8fb8b 100644 --- a/src/Type/Php/StrrevFunctionReturnTypeExtension.php +++ b/src/Type/Php/StrrevFunctionReturnTypeExtension.php @@ -6,8 +6,14 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Type\Accessory\AccessoryLowercaseStringType; +use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; +use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; +use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; +use PHPStan\Type\IntersectionType; +use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function count; @@ -17,10 +23,6 @@ final class StrrevFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - public function __construct(private StrShuffleFunctionReturnTypeExtension $strShuffleExtension) - { - } - public function isFunctionSupported(FunctionReflection $functionReflection): bool { return $functionReflection->getName() === 'strrev'; @@ -48,8 +50,26 @@ public function getTypeFromFunctionCall( return TypeCombinator::union(...$resultTypes); } - // Reversing a string reorders its bytes, just like shuffling it. - return $this->strShuffleExtension->getShuffledStringType($inputType); + $accessoryTypes = []; + if ($inputType->isNonFalsyString()->yes()) { + $accessoryTypes[] = new AccessoryNonFalsyStringType(); + } elseif ($inputType->isNonEmptyString()->yes()) { + $accessoryTypes[] = new AccessoryNonEmptyStringType(); + } + if ($inputType->isLowercaseString()->yes()) { + $accessoryTypes[] = new AccessoryLowercaseStringType(); + } + if ($inputType->isUppercaseString()->yes()) { + $accessoryTypes[] = new AccessoryUppercaseStringType(); + } + + if (count($accessoryTypes) > 0) { + $accessoryTypes[] = new StringType(); + + return new IntersectionType($accessoryTypes); + } + + return null; } } From b7a7245c40bfb5e6eff3974a0f1e04f83300bf12 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 18 Sep 2026 07:21:14 +0000 Subject: [PATCH 06/14] Describe pickArrayKeys() as an array cast of the array_rand() it mirrors Passing a hardcoded 2 as array_rand()'s $num sidestepped the bare-key branch, but it also pinned pickArrayKeys() to whatever array_rand() happens to infer for "more than one key" rather than to the call's own arguments - so any future refinement of array_rand() (array shapes, for instance) would be wrong here. Forward $num verbatim and cast the result to array instead. $num is validated to be between 1 and the size of the array, so a successful call always returns an array of keys, which is exactly what the cast expresses: picking a single key now gives array{'a'}|array{'b'} where array_rand() gives 'a'|'b'. Co-Authored-By: Claude Opus 5 --- .../Php/RandomizerMethodReturnTypeExtension.php | 13 ++++++++----- tests/PHPStan/Analyser/nsrt/bug-15256.php | 9 ++++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Type/Php/RandomizerMethodReturnTypeExtension.php b/src/Type/Php/RandomizerMethodReturnTypeExtension.php index 8c6e1fa97de..fc17ab82090 100644 --- a/src/Type/Php/RandomizerMethodReturnTypeExtension.php +++ b/src/Type/Php/RandomizerMethodReturnTypeExtension.php @@ -7,7 +7,6 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Name\FullyQualified; -use PhpParser\Node\Scalar\Int_; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\MethodReflection; @@ -53,13 +52,17 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method case 'shuffleArray': return $scope->getType($args[0]->value)->shuffleArray(); case 'pickArrayKeys': + if (count($args) < 2) { + return null; + } + // $num is validated to be between 1 and the size of the array, so unlike - // array_rand() a successful call always returns a list of keys - which is - // what array_rand() returns when asked for more than one key. + // array_rand() a successful call always returns an array of keys, even + // when a single key is picked - hence the cast to array. return $scope->getType($this->createFuncCall('array_rand', [ $args[0]->value, - new Int_(2), - ])); + $args[1]->value, + ]))->toArray(); case 'shuffleBytes': return $scope->getType($this->createFuncCall('str_shuffle', [$args[0]->value])); case 'getInt': diff --git a/tests/PHPStan/Analyser/nsrt/bug-15256.php b/tests/PHPStan/Analyser/nsrt/bug-15256.php index ea837d10159..36b2c331b49 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15256.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15256.php @@ -44,12 +44,14 @@ public static function pick3ArrayKeys(array $array): array * @param non-empty-array $nonEmptyArray * @param list $list * @param array $mixedKeys + * @param positive-int $num */ function arrayMethods( Randomizer $randomizer, array $nonEmptyArray, array $list, - array $mixedKeys + array $mixedKeys, + int $num ): void { assertType('non-empty-list', $randomizer->shuffleArray($nonEmptyArray)); @@ -62,6 +64,11 @@ function arrayMethods( assertType('non-empty-list>', $randomizer->pickArrayKeys($list, 2)); assertType('non-empty-list', $randomizer->pickArrayKeys($mixedKeys, 2)); assertType("non-empty-list<'a'|'b'>", $randomizer->pickArrayKeys(['a' => 1, 'b' => 2], 2)); + + // Unlike array_rand(), picking a single key still returns an array. + assertType('array{string}', $randomizer->pickArrayKeys($nonEmptyArray, 1)); + assertType("array{'a'}|array{'b'}", $randomizer->pickArrayKeys(['a' => 1, 'b' => 2], 1)); + assertType('non-empty-list', $randomizer->pickArrayKeys($nonEmptyArray, $num)); } /** From 4a92ccfefce42d0887a8de311f4b9919006bdb28 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 18 Sep 2026 09:30:46 +0200 Subject: [PATCH 07/14] Fix Ci --- build/ignore-by-php-version.neon.php | 4 ++++ build/randomizer.neon | 7 +++++++ 2 files changed, 11 insertions(+) create mode 100644 build/randomizer.neon diff --git a/build/ignore-by-php-version.neon.php b/build/ignore-by-php-version.neon.php index 1eaaa6863dc..59911622d34 100644 --- a/build/ignore-by-php-version.neon.php +++ b/build/ignore-by-php-version.neon.php @@ -21,6 +21,10 @@ $includes[] = __DIR__ . '/more-enum-adapter-errors.neon'; } +if (PHP_VERSION_ID < 80200) { + $includes[] = __DIR__ . '/randomizer.neon'; +} + if (PHP_VERSION_ID < 80000) { $includes[] = __DIR__ . '/spl-autoload-functions-pre-php-7.neon'; } else { diff --git a/build/randomizer.neon b/build/randomizer.neon new file mode 100644 index 00000000000..102ea874824 --- /dev/null +++ b/build/randomizer.neon @@ -0,0 +1,7 @@ +parameters: + ignoreErrors: + - + message: '#^Class Random\\Randomizer not found\.$#' + identifier: class.notFound + count: 1 + path: ../src/Type/Php/RandomizerMethodReturnTypeExtension.php From 3fcec8188ed7df36870a246c8924b2135dee9f36 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 18 Sep 2026 07:46:10 +0000 Subject: [PATCH 08/14] Guard the Randomizer test file behind lint >= 8.2 Random\Randomizer is a PHP 8.2 class, so the assertions in bug-15256.php only hold on 8.2+. Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Analyser/nsrt/bug-15256.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-15256.php b/tests/PHPStan/Analyser/nsrt/bug-15256.php index 36b2c331b49..5ddfe636f74 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15256.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15256.php @@ -1,4 +1,6 @@ -= 8.2 + +declare(strict_types = 1); namespace Bug15256; From 46a7a2138f6314c27970809d6aed4088eada3d0c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 18 Sep 2026 08:58:00 +0000 Subject: [PATCH 09/14] Drop the now-empty str_shuffle() stub The declaration is identical to the one phpstorm-stubs already provides, so with the conditional @return gone it adds nothing. Co-Authored-By: Claude Opus 5 --- stubs/core.stub | 2 -- 1 file changed, 2 deletions(-) diff --git a/stubs/core.stub b/stubs/core.stub index a0ffdc20a28..32d59f2b10d 100644 --- a/stubs/core.stub +++ b/stubs/core.stub @@ -132,8 +132,6 @@ function base64_encode(string $string) : string {} */ function bin2hex(string $string): string {} -function str_shuffle(string $string): string {} - /** * @param array $result * @param-out array|string> $result From bd8eb9fda04a155f22cad127fa24ff5d86b2f92c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 18 Sep 2026 09:11:20 +0000 Subject: [PATCH 10/14] Assert str_shuffle() over an intersection of accessory string types Covers uppercase-string&non-empty-string, where more than one accessory type has to survive the shuffle at once. Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Analyser/nsrt/str-shuffle.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/str-shuffle.php b/tests/PHPStan/Analyser/nsrt/str-shuffle.php index 1f5818697ea..ab0721361b8 100644 --- a/tests/PHPStan/Analyser/nsrt/str-shuffle.php +++ b/tests/PHPStan/Analyser/nsrt/str-shuffle.php @@ -13,6 +13,7 @@ class X { * @param numeric-string $numericString * @param lowercase-string $lowercaseString * @param uppercase-string $uppercaseString + * @param uppercase-string&non-empty-string $uppercaseNonEmptyString */ function doFoo( string $s, @@ -20,7 +21,8 @@ function doFoo( string $nonFalsyString, string $numericString, string $lowercaseString, - string $uppercaseString + string $uppercaseString, + string $uppercaseNonEmptyString ):void { assertType('lowercase-string&non-falsy-string', str_shuffle(self::ABC)); assertType('lowercase-string&non-falsy-string', str_shuffle('abc')); @@ -30,5 +32,6 @@ function doFoo( assertType('non-empty-string', str_shuffle($numericString)); assertType('lowercase-string', str_shuffle($lowercaseString)); assertType('uppercase-string', str_shuffle($uppercaseString)); + assertType('non-empty-string&uppercase-string', str_shuffle($uppercaseNonEmptyString)); } } From a4f9d63a4a21a73258dceefb011f4a21fc4cb525 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 18 Sep 2026 09:09:52 +0000 Subject: [PATCH 11/14] Cover pickArrayKeys() over arrays with numeric string keys Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Analyser/nsrt/bug-15256.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-15256.php b/tests/PHPStan/Analyser/nsrt/bug-15256.php index 5ddfe636f74..dcb81db368f 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15256.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15256.php @@ -46,6 +46,7 @@ public static function pick3ArrayKeys(array $array): array * @param non-empty-array $nonEmptyArray * @param list $list * @param array $mixedKeys + * @param non-empty-array $numericStringKeys * @param positive-int $num */ function arrayMethods( @@ -53,6 +54,7 @@ function arrayMethods( array $nonEmptyArray, array $list, array $mixedKeys, + array $numericStringKeys, int $num ): void { @@ -67,6 +69,11 @@ function arrayMethods( assertType('non-empty-list', $randomizer->pickArrayKeys($mixedKeys, 2)); assertType("non-empty-list<'a'|'b'>", $randomizer->pickArrayKeys(['a' => 1, 'b' => 2], 2)); + // Numeric string keys are cast to integers, just like array_rand() describes them. + assertType('non-empty-list', $randomizer->pickArrayKeys($numericStringKeys, 2)); + assertType('non-empty-list<1|2>', $randomizer->pickArrayKeys(['1' => 'a', '2' => 'b'], 2)); + assertType("non-empty-list<1|'b'>", $randomizer->pickArrayKeys(['1' => 'a', 'b' => 'b'], 2)); + // Unlike array_rand(), picking a single key still returns an array. assertType('array{string}', $randomizer->pickArrayKeys($nonEmptyArray, 1)); assertType("array{'a'}|array{'b'}", $randomizer->pickArrayKeys(['a' => 1, 'b' => 2], 1)); From fc35282da67f60855e0aa8c96c0ac56107ec7891 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 18 Sep 2026 09:20:16 +0000 Subject: [PATCH 12/14] Cover Randomizer::getFloat() and nextFloat() Both were added in PHP 8.3 alongside getBytesFromString(), but unlike it they have nothing more precise to say than the float they declare - PHPStan has no float range types - so they get no signature map entry, only assertions pinning that down. Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Analyser/nsrt/bug-15256-83.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-15256-83.php b/tests/PHPStan/Analyser/nsrt/bug-15256-83.php index 57ea04f3f70..660e53dc9bb 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15256-83.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15256-83.php @@ -4,6 +4,7 @@ namespace Bug15256Php83; +use Random\IntervalBoundary; use Random\Randomizer; use function PHPStan\Testing\assertType; @@ -21,3 +22,12 @@ function getBytesFromString( assertType('non-empty-string', $randomizer->getBytesFromString($lowercaseString, 5)); assertType('non-empty-string', $randomizer->getBytesFromString('abc', 5)); } + +// PHPStan has no float range types, so there is nothing more precise to say +// about these than the float the class declares. +function floatMethods(Randomizer $randomizer, float $float): void +{ + assertType('float', $randomizer->nextFloat()); + assertType('float', $randomizer->getFloat(0.0, 1.0)); + assertType('float', $randomizer->getFloat($float, $float, IntervalBoundary::ClosedClosed)); +} From 85a6a1da8f0b1dea642d680e6632d5966a4224ff Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 18 Sep 2026 09:21:13 +0000 Subject: [PATCH 13/14] Cover Randomizer methods against same-named functions and class in the namespace Co-Authored-By: Claude Opus 5 --- .../nsrt/bug-15256-shadowed-functions.php | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15256-shadowed-functions.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-15256-shadowed-functions.php b/tests/PHPStan/Analyser/nsrt/bug-15256-shadowed-functions.php new file mode 100644 index 00000000000..fbfa80780d3 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15256-shadowed-functions.php @@ -0,0 +1,95 @@ += 8.2 + +declare(strict_types = 1); + +namespace Bug15256Shadowed; + +use function PHPStan\Testing\assertType; + +/** + * @param non-empty-array $array + * @return array{'shadowed'} + */ +function array_rand(array $array, int $num = 1): array +{ + return ['shadowed']; +} + +/** + * @return 'shadowed' + */ +function str_shuffle(string $string): string +{ + return 'shadowed'; +} + +/** + * @return 999 + */ +function random_int(int $min, int $max): int +{ + return 999; +} + +class Randomizer +{ + + /** + * @param array $array + * @return array + */ + public function pickArrayKeys(array $array, int $num): array + { + return []; + } + + public function shuffleBytes(string $bytes): string + { + return $bytes; + } + + public function getInt(int $min, int $max): int + { + return $min; + } + +} + +/** + * The extension builds the calls it borrows types from with fully qualified names, + * so functions of the same name in the current namespace do not take over. + * + * @param non-empty-array $nonEmptyArray + * @param non-empty-string $nonEmptyString + */ +function shadowedFunctions( + \Random\Randomizer $randomizer, + array $nonEmptyArray, + string $nonEmptyString +): void +{ + assertType("array{'shadowed'}", array_rand($nonEmptyArray, 2)); + assertType("'shadowed'", str_shuffle($nonEmptyString)); + assertType('999', random_int(1, 10)); + + assertType('non-empty-list', $randomizer->pickArrayKeys($nonEmptyArray, 2)); + assertType('non-empty-string', $randomizer->shuffleBytes($nonEmptyString)); + assertType('int<1, 10>', $randomizer->getInt(1, 10)); +} + +/** + * A class of the same name in another namespace is not described by the extension. + * + * @param non-empty-array $nonEmptyArray + * @param non-empty-string $nonEmptyString + */ +function shadowedClass( + Randomizer $randomizer, + array $nonEmptyArray, + string $nonEmptyString +): void +{ + assertType('array', $randomizer->pickArrayKeys($nonEmptyArray, 2)); + assertType('string', $randomizer->shuffleBytes($nonEmptyString)); + assertType('int', $randomizer->getInt(1, 10)); +} From 27f4408a62fabec367f73ac535c172f1d5063d53 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 18 Sep 2026 09:24:44 +0000 Subject: [PATCH 14/14] Cover pickArrayKeys() over decimal-int-string and non-decimal-int-string keys Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Analyser/nsrt/bug-15256.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-15256.php b/tests/PHPStan/Analyser/nsrt/bug-15256.php index dcb81db368f..27e839a3ec2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15256.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15256.php @@ -47,6 +47,8 @@ public static function pick3ArrayKeys(array $array): array * @param list $list * @param array $mixedKeys * @param non-empty-array $numericStringKeys + * @param non-empty-array $decimalIntStringKeys + * @param non-empty-array $nonDecimalIntStringKeys * @param positive-int $num */ function arrayMethods( @@ -55,6 +57,8 @@ function arrayMethods( array $list, array $mixedKeys, array $numericStringKeys, + array $decimalIntStringKeys, + array $nonDecimalIntStringKeys, int $num ): void { @@ -74,6 +78,12 @@ function arrayMethods( assertType('non-empty-list<1|2>', $randomizer->pickArrayKeys(['1' => 'a', '2' => 'b'], 2)); assertType("non-empty-list<1|'b'>", $randomizer->pickArrayKeys(['1' => 'a', 'b' => 'b'], 2)); + // A decimal-int-string key is always cast to an integer, a non-decimal-int-string one never is. + assertType('non-empty-list', $randomizer->pickArrayKeys($decimalIntStringKeys, 2)); + assertType('non-empty-list', $randomizer->pickArrayKeys($nonDecimalIntStringKeys, 2)); + assertType('array{int}', $randomizer->pickArrayKeys($decimalIntStringKeys, 1)); + assertType('array{non-decimal-int-string}', $randomizer->pickArrayKeys($nonDecimalIntStringKeys, 1)); + // Unlike array_rand(), picking a single key still returns an array. assertType('array{string}', $randomizer->pickArrayKeys($nonEmptyArray, 1)); assertType("array{'a'}|array{'b'}", $randomizer->pickArrayKeys(['a' => 1, 'b' => 2], 1));