Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Describe Randomizer methods through the extensions of the functions t…
…hey 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 <noreply@anthropic.com>
  • Loading branch information
2 people authored and staabm committed Sep 18, 2026
commit 2191d73dc6535676a8712313a9203a42e8533a11
37 changes: 31 additions & 6 deletions src/Type/Php/ArrayRandFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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()) {
Expand All @@ -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(),
);
}

}
46 changes: 0 additions & 46 deletions src/Type/Php/RandomArrayKeysReturnTypeHelper.php

This file was deleted.

57 changes: 50 additions & 7 deletions src/Type/Php/RandomIntFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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),
);
}

Expand Down
62 changes: 0 additions & 62 deletions src/Type/Php/RandomIntRangeHelper.php

This file was deleted.

19 changes: 12 additions & 7 deletions src/Type/Php/RandomizerMethodReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
{
}
Expand Down Expand Up @@ -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(),
);
Expand Down
38 changes: 33 additions & 5 deletions src/Type/Php/StrShuffleFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
}

}
Loading