Skip to content

Commit d231b6d

Browse files
SanderMullerclaude
andcommitted
Add null to array_map() callback parameters of shorter arrays
With more than one array, array_map() passes null for the missing indexes of each shorter array. When the size of every array is known, the callback parameter of an array that can be shorter than another one now gets null. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent de22776 commit d231b6d

12 files changed

Lines changed: 361 additions & 40 deletions

‎src/Analyser/ExprHandler/Helper/ContextualClosureParameterResolver.php‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use PHPStan\Parser\ArrowFunctionArgVisitor;
1414
use PHPStan\Parser\ClosureArgVisitor;
1515
use PHPStan\Parser\ImmediatelyInvokedClosureVisitor;
16+
use PHPStan\Reflection\ArrayMapCallbackParameterTypes;
1617
use PHPStan\Reflection\Native\NativeParameterReflection;
1718
use PHPStan\Reflection\ParameterReflection;
1819
use PHPStan\Reflection\PassedByReference;
@@ -90,17 +91,38 @@ public function resolve(
9091
);
9192
}
9293

93-
$parameters = [];
94-
$nativeParameters = [];
94+
$types = [];
95+
$nativeTypes = [];
96+
$arrayTypes = [];
97+
$nativeArrayTypes = [];
98+
$hasUnpackedArg = false;
9599
foreach ($intrinsicArgs as $arg) {
100+
$hasUnpackedArg = $hasUnpackedArg || $arg->unpack;
96101
$result = $storage !== null ? $storage->findExpressionResult($arg->value) : null;
97102
$type = $result !== null ? $result->getType() : $this->nodeScopeResolver->readScopeStateOrSyntheticType($arg->value, $scope);
98103
$nativeType = $result !== null ? $result->getNativeType() : $this->nodeScopeResolver->readScopeStateOrSyntheticType($arg->value, $scope->doNotTreatPhpDocTypesAsCertain());
99104
if ($arrayMapArgs !== null) {
105+
$arrayTypes[] = $type;
106+
$nativeArrayTypes[] = $nativeType;
100107
$type = $type->getIterableValueType();
101108
$nativeType = $nativeType->getIterableValueType();
102109
}
110+
$types[] = $type;
111+
$nativeTypes[] = $nativeType;
112+
}
113+
114+
// an unpacked arg is one parameter here, so its arrays cannot be told apart
115+
if ($arrayMapArgs !== null && !$hasUnpackedArg) {
116+
$types = ArrayMapCallbackParameterTypes::addPaddingNull($types, $arrayTypes);
117+
$nativeTypes = ArrayMapCallbackParameterTypes::addPaddingNull($nativeTypes, $nativeArrayTypes);
118+
}
119+
120+
$parameters = [];
121+
foreach ($types as $type) {
103122
$parameters[] = new DummyParameter('item', $type, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
123+
}
124+
$nativeParameters = [];
125+
foreach ($nativeTypes as $nativeType) {
104126
$nativeParameters[] = new DummyParameter('item', $nativeType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
105127
}
106128

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Reflection;
4+
5+
use PHPStan\Turbo\ReferencedByTurboExtension;
6+
use PHPStan\Type\Type;
7+
use PHPStan\Type\TypeCombinator;
8+
use function count;
9+
use function is_int;
10+
use function max;
11+
12+
/**
13+
* With more than one array, array_map() calls the callback once per index of
14+
* the longest array, and passes null for each array that is shorter.
15+
*/
16+
#[ReferencedByTurboExtension(key: 'arrayMapCallbackParameterTypes')]
17+
final class ArrayMapCallbackParameterTypes
18+
{
19+
20+
/**
21+
* Adds null to the value type of each array that is shorter than another one
22+
* when both have their largest size. Only when the size of every array is known.
23+
*
24+
* The largest sizes are compared, not the smallest, because two arrays can be
25+
* one array: array_map($cb, $a, $a) never passes null, whatever sizes $a can have.
26+
*
27+
* @param list<Type> $valueTypes
28+
* @param list<Type> $arrayTypes the array each value type comes from
29+
* @return list<Type>
30+
*/
31+
public static function addPaddingNull(array $valueTypes, array $arrayTypes): array
32+
{
33+
if (count($arrayTypes) < 2) {
34+
return $valueTypes;
35+
}
36+
37+
$maxSizes = [];
38+
foreach ($arrayTypes as $i => $arrayType) {
39+
$sizes = [];
40+
foreach ($arrayType->getArraySize()->getFiniteTypes() as $sizeType) {
41+
foreach ($sizeType->getConstantScalarValues() as $size) {
42+
if (!is_int($size)) {
43+
continue;
44+
}
45+
46+
$sizes[] = $size;
47+
}
48+
}
49+
50+
if ($sizes === []) {
51+
// general arrays can have the same size, but the type system cannot say so
52+
return $valueTypes;
53+
}
54+
55+
$maxSizes[$i] = max($sizes);
56+
}
57+
58+
$longestSize = max($maxSizes);
59+
foreach ($valueTypes as $i => $valueType) {
60+
if ($maxSizes[$i] === $longestSize) {
61+
continue;
62+
}
63+
64+
$valueTypes[$i] = TypeCombinator::addNull($valueType);
65+
}
66+
67+
return $valueTypes;
68+
}
69+
70+
}

‎src/Reflection/ParametersAcceptorSelector.php‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,10 @@ public static function applyIntrinsicArgOverrides(
206206
) {
207207
$arrayMapArgs = $args[0]->value->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME);
208208
if ($arrayMapArgs !== null) {
209-
$callbackParameters = [];
210-
$nativeCallbackParameters = [];
209+
$callbackValueTypes = [];
210+
$callbackArrayTypes = [];
211+
$nativeCallbackValueTypes = [];
212+
$nativeCallbackArrayTypes = [];
211213
foreach ($arrayMapArgs as $arg) {
212214
$argType = ($typeGetter)($arg->value);
213215
$nativeArgType = ($nativeTypeGetter)($arg->value);
@@ -217,7 +219,8 @@ public static function applyIntrinsicArgOverrides(
217219
foreach ($constantArrays as $constantArray) {
218220
$valueTypes = $constantArray->getValueTypes();
219221
foreach ($valueTypes as $valueType) {
220-
$callbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
222+
$callbackValueTypes[] = ($iterableValueTypeGetter)($valueType);
223+
$callbackArrayTypes[] = $valueType;
221224
}
222225
}
223226
}
@@ -226,16 +229,28 @@ public static function applyIntrinsicArgOverrides(
226229
foreach ($nativeConstantArrays as $constantArray) {
227230
$valueTypes = $constantArray->getValueTypes();
228231
foreach ($valueTypes as $valueType) {
229-
$nativeCallbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
232+
$nativeCallbackValueTypes[] = ($iterableValueTypeGetter)($valueType);
233+
$nativeCallbackArrayTypes[] = $valueType;
230234
}
231235
}
232236
}
233237
} else {
234-
$callbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
235-
$nativeCallbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
238+
$callbackValueTypes[] = ($iterableValueTypeGetter)($argType);
239+
$callbackArrayTypes[] = $argType;
240+
$nativeCallbackValueTypes[] = ($iterableValueTypeGetter)($nativeArgType);
241+
$nativeCallbackArrayTypes[] = $nativeArgType;
236242
}
237243
}
238244

245+
$callbackParameters = [];
246+
foreach (ArrayMapCallbackParameterTypes::addPaddingNull($callbackValueTypes, $callbackArrayTypes) as $valueType) {
247+
$callbackParameters[] = new DummyParameter('item', $valueType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
248+
}
249+
$nativeCallbackParameters = [];
250+
foreach (ArrayMapCallbackParameterTypes::addPaddingNull($nativeCallbackValueTypes, $nativeCallbackArrayTypes) as $valueType) {
251+
$nativeCallbackParameters[] = new DummyParameter('item', $valueType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
252+
}
253+
239254
$acceptor = $parametersAcceptors[0];
240255
$parameters = $acceptor->getParameters();
241256
if (isset($parameters[0])) {

‎src/Type/Php/ArrayMapFunctionReturnTypeExtension.php‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use PHPStan\DependencyInjection\AutowiredService;
99
use PHPStan\Node\Expr\TypeExpr;
1010
use PHPStan\Parser\ArrayMapArgVisitor;
11+
use PHPStan\Reflection\ArrayMapCallbackParameterTypes;
1112
use PHPStan\Reflection\FunctionReflection;
1213
use PHPStan\TrinaryLogic;
1314
use PHPStan\Type\Accessory\AccessoryArrayListType;
@@ -25,6 +26,7 @@
2526
use PHPStan\Type\Type;
2627
use PHPStan\Type\TypeCombinator;
2728
use PHPStan\Type\TypeUtils;
29+
use function array_filter;
2830
use function array_map;
2931
use function array_reduce;
3032
use function array_slice;
@@ -53,11 +55,17 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
5355
$callableIsNull = $callableType->isNull()->yes();
5456

5557
if ($callableType->isCallable()->yes()) {
58+
$arrayArgs = array_slice($args, 1);
59+
$arrayTypes = array_map(static fn (Node\Arg $arg) => $scope->getType($arg->value), $arrayArgs);
60+
$callbackValueTypes = array_map(static fn (Type $arrayType) => $arrayType->getIterableValueType(), $arrayTypes);
61+
if (count(array_filter($arrayArgs, static fn (Node\Arg $arg) => $arg->unpack)) === 0) {
62+
$callbackValueTypes = ArrayMapCallbackParameterTypes::addPaddingNull($callbackValueTypes, $arrayTypes);
63+
}
5664
$valueType = $scope->getType(new FuncCall(
5765
$callback,
5866
array_map(
59-
static fn (Node\Arg $arg) => new Node\Arg(new TypeExpr($scope->getType($arg->value)->getIterableValueType())),
60-
array_slice($args, 1),
67+
static fn (Type $valueType) => new Node\Arg(new TypeExpr($valueType)),
68+
$callbackValueTypes,
6169
),
6270
));
6371
} elseif ($callableIsNull) {

‎src/Type/Php/ArrayMapParameterClosureTypeExtension.php‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use PhpParser\Node\Expr\FuncCall;
66
use PHPStan\Analyser\Scope;
77
use PHPStan\DependencyInjection\AutowiredService;
8+
use PHPStan\Reflection\ArrayMapCallbackParameterTypes;
89
use PHPStan\Reflection\FunctionReflection;
910
use PHPStan\Reflection\Native\NativeParameterReflection;
1011
use PHPStan\Reflection\ParameterReflection;
@@ -31,7 +32,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
3132
return null;
3233
}
3334

34-
$callbackParameters = [];
35+
$valueTypes = [];
36+
$arrayTypes = [];
3537
$argCount = count($args);
3638
for ($i = 1; $i < $argCount; $i++) {
3739
$arg = $args[$i];
@@ -43,14 +45,21 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
4345
}
4446
foreach ($constantArrays as $constantArray) {
4547
foreach ($constantArray->getValueTypes() as $valueType) {
46-
$callbackParameters[] = $this->createParameter($scope->getIterableValueType($valueType));
48+
$valueTypes[] = $scope->getIterableValueType($valueType);
49+
$arrayTypes[] = $valueType;
4750
}
4851
}
4952
} else {
50-
$callbackParameters[] = $this->createParameter($scope->getIterableValueType($arrayType));
53+
$valueTypes[] = $scope->getIterableValueType($arrayType);
54+
$arrayTypes[] = $arrayType;
5155
}
5256
}
5357

58+
$callbackParameters = [];
59+
foreach (ArrayMapCallbackParameterTypes::addPaddingNull($valueTypes, $arrayTypes) as $valueType) {
60+
$callbackParameters[] = $this->createParameter($valueType);
61+
}
62+
5463
return new ClosureType($callbackParameters, new MixedType());
5564
}
5665

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
namespace Bug13217;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class Foo
8+
{
9+
10+
public function shorterSecondArray(): void
11+
{
12+
array_map(function ($a, $b) {
13+
assertType('1|2', $a);
14+
assertType('3|null', $b);
15+
}, [1, 2], [3]);
16+
17+
array_map(fn ($a, $b) => assertType('3|null', $b), [1, 2], [3]);
18+
19+
assertType('non-empty-list<1|3>', array_map(fn ($a, $b) => $b ?? 1, [1, 2], [3]));
20+
}
21+
22+
public function shorterFirstArray(): void
23+
{
24+
array_map(function ($a, $b) {
25+
assertType('1|null', $a);
26+
assertType('3|4', $b);
27+
}, [1], [3, 4]);
28+
}
29+
30+
public function sameSize(): void
31+
{
32+
array_map(function ($a, $b) {
33+
assertType('1|2', $a);
34+
assertType('3|4', $b);
35+
}, [1, 2], [3, 4]);
36+
}
37+
38+
/**
39+
* @param array{1, 2}|array{1} $maybe
40+
*/
41+
public function sizeRange(array $maybe): void
42+
{
43+
array_map(function ($a, $b) {
44+
assertType('1|2|null', $a);
45+
assertType('3|4|5', $b);
46+
}, $maybe, [3, 4, 5]);
47+
48+
array_map(function ($a, $b) {
49+
assertType('1|2', $a);
50+
assertType('3|null', $b);
51+
}, $maybe, [3]);
52+
53+
array_map(function ($a, $b) {
54+
assertType('1|2', $a);
55+
assertType('1|2', $b);
56+
}, $maybe, $maybe);
57+
}
58+
59+
public function unpacked(): void
60+
{
61+
// the closure gets one parameter per arg here, not one per unpacked array
62+
array_map(function ($a, $b) {
63+
assertType('1|2|3', $a);
64+
assertType('array{4, 5, 6}', $b);
65+
}, [1, 2, 3], ...[[4, 5, 6]]);
66+
}
67+
68+
public function singleArray(): void
69+
{
70+
array_map(function ($a) {
71+
assertType('1|2', $a);
72+
}, [1, 2]);
73+
}
74+
75+
/**
76+
* @param list<int> $ints
77+
* @param list<string> $strings
78+
*/
79+
public function generalArrays(array $ints, array $strings): void
80+
{
81+
array_map(function ($a, $b) {
82+
assertType('int', $a);
83+
assertType('string', $b);
84+
}, $ints, $strings);
85+
86+
array_map(function ($a) {
87+
assertType('int', $a);
88+
}, $ints);
89+
90+
array_map(function ($a, $b) {
91+
assertType('int', $a);
92+
assertType('3', $b);
93+
}, $ints, [3]);
94+
}
95+
96+
public function returnType(): void
97+
{
98+
assertType('non-empty-list<3|null>', array_map('Bug13217\\pick', [1, 2], [3]));
99+
}
100+
101+
}
102+
103+
/**
104+
* @template T
105+
* @param mixed $a
106+
* @param T $b
107+
* @return T
108+
*/
109+
function pick($a, $b)
110+
{
111+
return $b;
112+
}

‎tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,11 @@ public function testInTrait(): void
706706
]);
707707
}
708708

709+
public function testBug13217(): void
710+
{
711+
$this->analyse([__DIR__ . '/data/bug-13217.php'], []);
712+
}
713+
709714
public function testBug12780(): void
710715
{
711716
$this->analyse([__DIR__ . '/data/bug-12780.php'], []);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<?php declare(strict_types = 1);
2+
3+
array_map(fn($_, $bValue) => $bValue ?? 1, [1, 2], [3]);

0 commit comments

Comments
 (0)