Skip to content

Commit b6839d2

Browse files
zonuexeclaude
andcommitted
Widen the key type array_key_first() and array_key_last() return
PHP casts a decimal-integer string array key ("123") to int, so `array_key_first([$string => null])` is not necessarily a string. PHPStan inferred `string` for it and reported `is_int()` on the result as always false. UnsafeArrayStringKeyCastingTraverser already modelled that cast, but only for the key type an array *has* — a type that also decides how the array describes itself and what it accepts, which is why only `reportUnsafeArrayStringKeyCasting: detect` widens it. Widening it with the toggle off turns `array<string, X>` into `array<X>` everywhere. castReadKeyType() is the second entry point, for a key that leaves the array as a value of its own. With the toggle off it widens `string` to the benevolent `(int|string)`, so neither branch reports anything; `detect` and `prevent` keep the types they have today. unionWithReadKeyType() adds the `null` an empty array gives back without losing the benevolence on the way. The remaining accessors follow in the next commit. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d56197a commit b6839d2

4 files changed

Lines changed: 87 additions & 14 deletions

File tree

‎src/Type/Php/ArrayFirstLastDynamicReturnTypeExtension.php‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use PHPStan\ShouldNotHappenException;
1010
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
1111
use PHPStan\Type\NullType;
12+
use PHPStan\Type\Traverser\UnsafeArrayStringKeyCastingTraverser;
1213
use PHPStan\Type\Type;
1314
use PHPStan\Type\TypeCombinator;
1415
use function count;
@@ -46,8 +47,12 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
4647
switch ($functionReflection->getName()) {
4748
case 'array_key_first':
4849
case 'array_key_last':
49-
$resultType = $argType->getIterableKeyType();
50-
break;
50+
$keyType = $argType->getIterableKeyType();
51+
if ($iterableAtLeastOnce->yes()) {
52+
return UnsafeArrayStringKeyCastingTraverser::castReadKeyType($keyType);
53+
}
54+
55+
return UnsafeArrayStringKeyCastingTraverser::unionWithReadKeyType($keyType, new NullType());
5156
case 'array_first':
5257
case 'array_last':
5358
$resultType = $argType->getIterableValueType();

‎src/Type/Traverser/UnsafeArrayStringKeyCastingTraverser.php‎

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle;
66
use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType;
7+
use PHPStan\Type\BenevolentUnionType;
78
use PHPStan\Type\IntegerType;
89
use PHPStan\Type\Type;
910
use PHPStan\Type\TypeCombinator;
@@ -12,23 +13,82 @@
1213
use PHPStan\Type\UnionType;
1314

1415
/**
15-
* Under `reportUnsafeArrayStringKeyCasting: detect`, PHP casts a decimal-integer
16-
* string array key ("123") to int when iterating, so the iterable key type widens
17-
* from `string` to `int | non-decimal-int-string`. Shared by ArrayType and
18-
* ConstantArrayType so both representations agree — otherwise comparing a general
19-
* array (cast key) against a constant-array shape (raw key) yields a spurious
20-
* `Maybe`.
16+
* PHP casts a decimal-integer string array key ("123") to int, so an array with
17+
* a `string` key type can hand back an int. There are two places that matters,
18+
* and they widen differently on purpose.
19+
*
20+
* {@see self::castKeyType()} widens the key type an array *has*. That type also
21+
* decides how the array describes itself and what it accepts, so only
22+
* `reportUnsafeArrayStringKeyCasting: detect` widens there — it opts into the
23+
* resulting reports. Under `prevent` there is nothing to do, because PHPDoc
24+
* string key types are narrowed to `non-decimal-int-string` when resolved.
25+
*
26+
* {@see self::castReadKeyType()} widens a key *taken out* of an array and handed
27+
* back as a value of its own — `array_key_first()`, `array_keys()`, `key()`,
28+
* `array_flip()`, … With the toggle off it widens `string` to the benevolent
29+
* `(int|string)`, which stops `array_key_first([$string => null])` from looking
30+
* like a certain `string` without making either branch report an error.
31+
*
32+
* `foreach` keys are deliberately not widened with the toggle off: the key
33+
* usually goes straight back into another array (`$result[$k] = …`), and a
34+
* benevolent `(int|string)` key collapses that array to `array<mixed, …>`.
35+
* `detect` is the level that gets accurate `foreach` keys.
36+
*
37+
* Both are shared by ArrayType and ConstantArrayType so the two representations
38+
* agree — otherwise comparing a general array (cast key) against a
39+
* constant-array shape (raw key) yields a spurious `Maybe`.
2140
*/
2241
final class UnsafeArrayStringKeyCastingTraverser implements TypeTraverserCallable
2342
{
2443

44+
private function __construct(private bool $precise)
45+
{
46+
}
47+
2548
public static function castKeyType(Type $keyType): Type
2649
{
2750
if (ReportUnsafeArrayStringKeyCastingToggle::getLevel() !== ReportUnsafeArrayStringKeyCastingToggle::DETECT) {
2851
return $keyType;
2952
}
3053

31-
return TypeTraverser::map($keyType, new self());
54+
return TypeTraverser::map($keyType, new self(true));
55+
}
56+
57+
public static function castReadKeyType(Type $keyType): Type
58+
{
59+
$level = ReportUnsafeArrayStringKeyCastingToggle::getLevel();
60+
if ($level !== null) {
61+
// `detect` already widened the key type the array carries, and `prevent`
62+
// made sure it can't hold a decimal-integer string in the first place.
63+
return self::castKeyType($keyType);
64+
}
65+
66+
// A key type that already covers int has nothing to gain from the widening.
67+
// Leaving it alone also keeps it out of a BenevolentUnionType, so what is
68+
// checked against it stays as strict as it is today.
69+
if ($keyType->isSuperTypeOf(new IntegerType())->yes()) {
70+
return $keyType;
71+
}
72+
73+
return TypeTraverser::map($keyType, new self(false));
74+
}
75+
76+
/**
77+
* Adds the "there is no key" result an accessor returns for an empty array
78+
* (`null` for array_key_first(), `false` for array_search(), …).
79+
*
80+
* TypeCombinator alone would drop the benevolence of a widened key type and
81+
* start reporting on the very code the widening exists to leave alone.
82+
*/
83+
public static function unionWithReadKeyType(Type $keyType, Type $noKeyType): Type
84+
{
85+
$keyType = self::castReadKeyType($keyType);
86+
$union = TypeCombinator::union($keyType, $noKeyType);
87+
if ($keyType instanceof BenevolentUnionType && $union instanceof UnionType && !$union instanceof BenevolentUnionType) {
88+
return new BenevolentUnionType($union->getTypes());
89+
}
90+
91+
return $union;
3292
}
3393

3494
/**
@@ -41,6 +101,14 @@ public function traverse(Type $type, callable $traverse): Type
41101
}
42102

43103
if ($type->isString()->yes() && !$type->isDecimalIntegerString()->no()) {
104+
if (!$this->precise) {
105+
if ($type->isDecimalIntegerString()->yes()) {
106+
return new IntegerType();
107+
}
108+
109+
return new BenevolentUnionType([new IntegerType(), $type]);
110+
}
111+
44112
return TypeCombinator::union(
45113
new IntegerType(),
46114
TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)),

‎tests/PHPStan/Analyser/nsrt/bug-14245.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ function keyDifferentArray(array $arr): void {
104104
$list = foo();
105105
assertType('list<int>', $list);
106106
$list[array_key_first($arr)] = 37;
107-
assertType('non-empty-array<int|string, int>', $list);
107+
assertType('non-empty-array<int>', $list);
108108
}
109109

110110
function overwriteArraySearch($needle): void {

‎tests/PHPStan/Analyser/nsrt/php73_functions.php‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,12 @@ public function doFoo(
5454
assertType('mixed', json_decode($mixed));
5555
assertType('mixed', json_decode($mixed, false, 512, JSON_THROW_ON_ERROR | JSON_NUMERIC_CHECK));
5656
assertType('mixed', json_decode($mixed, false, 512, $integer | JSON_THROW_ON_ERROR | JSON_NUMERIC_CHECK));
57-
assertType('int|string|null', array_key_first($mixedArray));
58-
assertType('int|string|null', array_key_last($mixedArray));
57+
assertType('(int|string|null)', array_key_first($mixedArray));
58+
assertType('(int|string|null)', array_key_last($mixedArray));
5959
assertType('(int|string)', array_key_first($nonEmptyArray));
6060
assertType('(int|string)', array_key_last($nonEmptyArray));
61-
assertType('string|null', array_key_first($arrayWithStringKeys));
62-
assertType('string|null', array_key_last($arrayWithStringKeys));
61+
assertType('(int|string|null)', array_key_first($arrayWithStringKeys));
62+
assertType('(int|string|null)', array_key_last($arrayWithStringKeys));
6363
assertType('null', array_key_first($emptyArray));
6464
assertType('null', array_key_last($emptyArray));
6565
assertType('0|1|2', array_key_first($literalArray));

0 commit comments

Comments
 (0)