Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
9 changes: 7 additions & 2 deletions src/Type/Php/ArrayFirstLastDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
41 changes: 36 additions & 5 deletions src/Type/Php/ArrayRandFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,29 @@
use PHPStan\Type\Accessory\AccessoryArrayListType;
use PHPStan\Type\Accessory\NonEmptyArrayType;
use PHPStan\Type\ArrayType;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\IntegerRangeType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\StringType;
use PHPStan\Type\Traverser\UnsafeArrayStringKeyCastingTraverser;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\UnionType;
use function count;
use function is_int;

#[AutowiredService]
final class ArrayRandFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
{

/**
* Above this many picked keys the shape stops being worth its cost, and
* ConstantArrayTypeBuilder would degrade it to a general array anyway.
*/
private const KEY_COUNT_LIMIT = 100;

public function isFunctionSupported(FunctionReflection $functionReflection): bool
{
return $functionReflection->getName() === 'array_rand';
Expand Down Expand Up @@ -50,7 +59,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
return $keyType;
}

$keysListType = $this->getPickedKeysListType($firstArgType);
$keysListType = $this->getPickedKeysListType($firstArgType, $secondArgType);

$bigger2 = IntegerRangeType::fromInterval(2, null);
if ($bigger2->isSuperTypeOf($secondArgType)->yes()) {
Expand All @@ -68,16 +77,38 @@ private function getPickedKeyType(Type $arrayType): Type
return $arrayKeyType;
}

return TypeCombinator::intersect($arrayType->getIterableKeyType(), $arrayKeyType);
// The picked keys come back as values of their own, so PHP's array key
// cast applies to them.
return UnsafeArrayStringKeyCastingTraverser::castReadKeyType(
TypeCombinator::intersect($arrayType->getIterableKeyType(), $arrayKeyType),
);
}

/**
* Picking more than one key returns them re-indexed from zero, keeping their original order.
* Picking more than one key returns them re-indexed from zero, keeping their
* original order, so a known $num gives an exact tuple.
*/
private function getPickedKeysListType(Type $arrayType): Type
private function getPickedKeysListType(Type $arrayType, Type $numType): Type
{
$keyType = $this->getPickedKeyType($arrayType);

$constantNums = $numType->getConstantScalarValues();
if (
count($constantNums) === 1
&& is_int($constantNums[0])
&& $constantNums[0] >= 2
&& $constantNums[0] <= self::KEY_COUNT_LIMIT
) {
$builder = ConstantArrayTypeBuilder::createEmpty();
for ($i = 0; $i < $constantNums[0]; $i++) {
$builder->setOffsetValueType(new ConstantIntegerType($i), $keyType);
}

return $builder->getArray();
}

return TypeCombinator::intersect(
new ArrayType(new IntegerType(), $this->getPickedKeyType($arrayType)),
new ArrayType(new IntegerType(), $keyType),
new AccessoryArrayListType(),
new NonEmptyArrayType(),
);
Expand Down
82 changes: 75 additions & 7 deletions src/Type/Traverser/UnsafeArrayStringKeyCastingTraverser.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle;
use PHPStan\Turbo\ReferencedByTurboExtension;
use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType;
use PHPStan\Type\BenevolentUnionType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
Expand All @@ -13,24 +14,83 @@
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<mixed, …>`.
* `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`.
*/
#[ReferencedByTurboExtension(key: 'unsafeArrayStringKeyCastingTraverser')]
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()) {

Check warning on line 71 in src/Type/Traverser/UnsafeArrayStringKeyCastingTraverser.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\IsSuperTypeOfCalleeAndArgumentMutator": @@ @@ // 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()) { + if ((new IntegerType())->isSuperTypeOf($keyType)->yes()) { return $keyType; }

Check warning on line 71 in src/Type/Traverser/UnsafeArrayStringKeyCastingTraverser.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ // 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()) { + if (!$keyType->isSuperTypeOf(new IntegerType())->no()) { return $keyType; }
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;
}

/**
Expand All @@ -43,6 +103,14 @@
}

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)),
Expand Down
8 changes: 4 additions & 4 deletions tests/PHPStan/Analyser/nsrt/array-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -359,10 +359,10 @@
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('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<int|string>', array_rand([1 => 1, 2 => "2", $mixed => $mixed], 2));
assertType('array{1|2, 1|2}', array_rand([1 => 1, 2 => "b"], 2));
assertType("array{'a'|'b', 'a'|'b'}", array_rand(["a" => 1, "b" => "b"], 2));
assertType("array{2|'a', 2|'a'}", array_rand(["a" => 1, 2 => "b"], 2));
assertType('array{int|string, int|string}', 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));
Expand Down
39 changes: 39 additions & 0 deletions tests/PHPStan/Analyser/nsrt/array-rand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types = 1);

namespace ArrayRandReturnType;

use function PHPStan\Testing\assertType;

/**
* @param array{a: 1, b: 2, c: 3} $shape
* @param non-empty-list<string> $list
* @param non-empty-array<string, int> $strKeyed
* @param int<2, max> $atLeastTwo
* @param positive-int $positive
*/
function f(array $shape, array $list, array $strKeyed, int $atLeastTwo, int $positive, int $int): void
{
assertType("'a'|'b'|'c'", array_rand($shape));
assertType("'a'|'b'|'c'", array_rand($shape, 1));
assertType("array{'a'|'b'|'c', 'a'|'b'|'c'}", array_rand($shape, 2));
assertType("array{'a'|'b'|'c', 'a'|'b'|'c', 'a'|'b'|'c'}", array_rand($shape, 3));

assertType('int<0, max>', array_rand($list));
assertType('array{int<0, max>, int<0, max>}', array_rand($list, 2));

// a decimal-integer string key comes back as an int, see #15073
assertType('(int|string)', array_rand($strKeyed));
assertType('array{(int|string), (int|string)}', array_rand($strKeyed, 2));

// $num is known to be 2 or more, but not by how much
assertType("non-empty-list<'a'|'b'|'c'>", array_rand($shape, $atLeastTwo));

// $num may be 1, which gives back a single key instead of a list
assertType("'a'|'b'|'c'|non-empty-list<'a'|'b'|'c'>", array_rand($shape, $positive));
assertType("'a'|'b'|'c'|non-empty-list<'a'|'b'|'c'>", array_rand($shape, $int));

// past KEY_COUNT_LIMIT the shape gives way to a list
assertType('non-empty-list<(int|string)>', array_rand($strKeyed, 200));
}
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-14245.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ function keyDifferentArray(array $arr): void {
$list = foo();
assertType('list<int>', $list);
$list[array_key_first($arr)] = 37;
assertType('non-empty-array<int|string, int>', $list);
assertType('non-empty-array<int>', $list);
}

function overwriteArraySearch($needle): void {
Expand Down
49 changes: 49 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15073.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php // lint >= 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<int|string, int> $intOrString
* @param non-empty-array<int<0, max>|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]));
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ function shadowedFunctions(
assertType("'shadowed'", str_shuffle($nonEmptyString));
assertType('999', random_int(1, 10));

assertType('non-empty-list<string>', $randomizer->pickArrayKeys($nonEmptyArray, 2));
assertType('array{(int|string), (int|string)}', $randomizer->pickArrayKeys($nonEmptyArray, 2));
assertType('non-empty-string', $randomizer->shuffleBytes($nonEmptyString));
assertType('int<1, 10>', $randomizer->getInt(1, 10));
}
Expand Down
24 changes: 12 additions & 12 deletions tests/PHPStan/Analyser/nsrt/bug-15256.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public static function shuffle(array $array): array
public static function pick3ArrayKeys(array $array): array
{
$randomizer = new Randomizer();
assertType('non-empty-list<TKey of (int|string) (method Bug15256\Shuffle::pick3ArrayKeys(), argument)>', $randomizer->pickArrayKeys($array, 3));
assertType('array{TKey of (int|string) (method Bug15256\\Shuffle::pick3ArrayKeys(), argument), TKey of (int|string) (method Bug15256\\Shuffle::pick3ArrayKeys(), argument), TKey of (int|string) (method Bug15256\\Shuffle::pick3ArrayKeys(), argument)}', $randomizer->pickArrayKeys($array, 3));

return $randomizer->pickArrayKeys($array, 3);
}
Expand Down Expand Up @@ -68,26 +68,26 @@ function arrayMethods(
assertType('array{}', $randomizer->shuffleArray([]));
assertType('list<bool>', $randomizer->shuffleArray($mixedKeys));

assertType('non-empty-list<string>', $randomizer->pickArrayKeys($nonEmptyArray, 2));
assertType('non-empty-list<int<0, max>>', $randomizer->pickArrayKeys($list, 2));
assertType('non-empty-list<int|string>', $randomizer->pickArrayKeys($mixedKeys, 2));
assertType("non-empty-list<'a'|'b'>", $randomizer->pickArrayKeys(['a' => 1, 'b' => 2], 2));
assertType('array{(int|string), (int|string)}', $randomizer->pickArrayKeys($nonEmptyArray, 2));
assertType('array{int<0, max>, int<0, max>}', $randomizer->pickArrayKeys($list, 2));
assertType('array{int|string, int|string}', $randomizer->pickArrayKeys($mixedKeys, 2));
assertType("array{'a'|'b', '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<int|numeric-string>', $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));
assertType('array{int|numeric-string, int|numeric-string}', $randomizer->pickArrayKeys($numericStringKeys, 2));
assertType('array{1|2, 1|2}', $randomizer->pickArrayKeys(['1' => 'a', '2' => 'b'], 2));
assertType("array{1|'b', 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<int>', $randomizer->pickArrayKeys($decimalIntStringKeys, 2));
assertType('non-empty-list<non-decimal-int-string>', $randomizer->pickArrayKeys($nonDecimalIntStringKeys, 2));
assertType('array{int, int}', $randomizer->pickArrayKeys($decimalIntStringKeys, 2));
assertType('array{non-decimal-int-string, non-decimal-int-string}', $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{int}|array{string})', $randomizer->pickArrayKeys($nonEmptyArray, 1));
assertType("array{'a'}|array{'b'}", $randomizer->pickArrayKeys(['a' => 1, 'b' => 2], 1));
assertType('non-empty-list<string>', $randomizer->pickArrayKeys($nonEmptyArray, $num));
assertType('non-empty-list<(int|string)>', $randomizer->pickArrayKeys($nonEmptyArray, $num));
}

/**
Expand Down
3 changes: 2 additions & 1 deletion tests/PHPStan/Analyser/nsrt/bug-7905.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public function sayHello(array|null $data): void
$key = $data === null ? null : array_key_first($data);
if ($key !== null) {
assertType('non-empty-array<string, string>', $data);
assertType('string', $key);
// array_key_first() on a string-keyed array can hand back an int
assertType('(int|string)', $key);
echo $data[$key];
}
echo $key === null ? null : $data[$key];
Expand Down
Loading
Loading