Skip to content

Commit 83ceea2

Browse files
phpstan-botVincentLangletclaude
authored
Resolve per-element callback return types in array_map for closure and arrow function callbacks (#5716)
Co-authored-by: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d3d3bf1 commit 83ceea2

6 files changed

Lines changed: 134 additions & 7 deletions

File tree

src/Analyser/MutatingScope.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -926,7 +926,11 @@ public function getNodeKey(Expr $node): string
926926
&& (($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] ?? null) !== null)
927927
&& (($attributes['startFilePos'] ?? null) !== null)
928928
) {
929-
$key .= '/*' . $attributes['startFilePos'] . '*/';
929+
$key .= '/*' . $attributes['startFilePos'];
930+
foreach ($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] as $arg) {
931+
$key .= ':' . $this->exprPrinter->printExpr($arg->value);
932+
}
933+
$key .= '*/';
930934
}
931935

932936
if (($attributes[self::KEEP_VOID_ATTRIBUTE_NAME] ?? null) === true) {

src/Type/Php/ArrayMapFunctionReturnTypeExtension.php

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@
77
use PHPStan\Analyser\Scope;
88
use PHPStan\DependencyInjection\AutowiredService;
99
use PHPStan\Node\Expr\TypeExpr;
10+
use PHPStan\Parser\ArrayMapArgVisitor;
1011
use PHPStan\Reflection\FunctionReflection;
12+
use PHPStan\TrinaryLogic;
1113
use PHPStan\Type\Accessory\AccessoryArrayListType;
1214
use PHPStan\Type\Accessory\AccessoryType;
1315
use PHPStan\Type\Accessory\HasOffsetValueType;
1416
use PHPStan\Type\Accessory\NonEmptyArrayType;
1517
use PHPStan\Type\ArrayType;
18+
use PHPStan\Type\Constant\ConstantArrayType;
1619
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
1720
use PHPStan\Type\Constant\ConstantIntegerType;
1821
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
@@ -128,9 +131,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
128131
if (count($constantArrays) > 0) {
129132
$totalCount = TypeCombinator::countConstantArrayValueTypes($constantArrays) * TypeCombinator::countConstantArrayValueTypes([$valueType]);
130133
if ($totalCount < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
131-
$mappedArrayType = $arrayType->mapValueType(static fn (Type $type): Type => $scope->getType(new FuncCall($callback, [
132-
new Node\Arg(new TypeExpr($type)),
133-
])));
134+
$mappedArrayType = $arrayType->mapValueType(static fn (Type $type): Type => self::resolveCallbackReturnType($scope, $callback, $type));
134135
} else {
135136
$mappedArrayType = TypeCombinator::intersect(new ArrayType(
136137
$arrayType->getIterableKeyType(),
@@ -162,6 +163,26 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
162163
return $mappedArrayType;
163164
}
164165

166+
private static function resolveCallbackReturnType(Scope $scope, Node\Expr $callback, Type $argType): Type
167+
{
168+
if ($callback instanceof Node\Expr\Closure || $callback instanceof Node\Expr\ArrowFunction) {
169+
$clone = clone $callback;
170+
$wrappedType = new ConstantArrayType(
171+
[new ConstantIntegerType(0)],
172+
[$argType],
173+
isList: TrinaryLogic::createYes(),
174+
);
175+
$clone->setAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME, [new Node\Arg(new TypeExpr($wrappedType))]);
176+
$clone->setAttribute('phpstanCachedTypes', []);
177+
178+
return $scope->getType($clone)->getCallableParametersAcceptors($scope)[0]->getReturnType();
179+
}
180+
181+
return $scope->getType(new FuncCall($callback, [
182+
new Node\Arg(new TypeExpr($argType)),
183+
]));
184+
}
185+
165186
/**
166187
* @return list<AccessoryType>
167188
*/

tests/PHPStan/Analyser/nsrt/array-map.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ public function doFoo(): void
9595

9696
assertType("array{'0', '1'}", array_map('strval', $a));
9797
assertType("array{'0', '1'}", array_map(strval(...), $a));
98-
assertType("array{'0'|'1', '0'|'1'}", array_map(fn ($v) => strval($v), $a));
99-
assertType("array{'0'|'1', '0'|'1'}", array_map(fn ($v) => (string)$v, $a));
98+
assertType("array{'0', '1'}", array_map(fn ($v) => strval($v), $a));
99+
assertType("array{'0', '1'}", array_map(fn ($v) => (string)$v, $a));
100100
}
101101

102102
public function doFizzBuzz(): void

tests/PHPStan/Analyser/nsrt/bug-10685.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ function identity(mixed $value): mixed
1919

2020
public function doFoo(): void
2121
{
22-
assertType('array{1|2|3, 1|2|3, 1|2|3}', array_map(fn($i) => $i, [1, 2, 3]));
22+
assertType('array{1, 2, 3}', array_map(fn($i) => $i, [1, 2, 3]));
2323
assertType('array{1, 2, 3}', array_map($this->identity(...), [1, 2, 3]));
2424
}
2525

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug11656;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
class Test
10+
{
11+
/**
12+
* @param array{string[], string} $data
13+
*/
14+
public function test($data): string
15+
{
16+
$data = array_map(static fn ($value) => $value, $data);
17+
18+
assertType("array{array<string>, string}", $data);
19+
20+
return $data[1];
21+
}
22+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14649;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
enum Role: string
10+
{
11+
case OWNER = 'OWNER';
12+
case ADMIN = 'ADMIN';
13+
case EDITOR = 'EDITOR';
14+
15+
public function isGreaterThanOrEqual(Role $role): bool
16+
{
17+
$map = array_map(
18+
static fn (Role $role): string => $role->value,
19+
self::cases()
20+
);
21+
22+
assertType("array{'OWNER', 'ADMIN', 'EDITOR'}", $map);
23+
24+
$hierarchy = array_flip($map);
25+
26+
assertType("array{OWNER: 0, ADMIN: 1, EDITOR: 2}", $hierarchy);
27+
28+
return $hierarchy[$this->value] <= $hierarchy[$role->value];
29+
}
30+
}
31+
32+
function testArrowFunctionArithmetic(): void
33+
{
34+
$arr = [1, 2, 3];
35+
$result = array_map(fn(int $x): int => $x * 2, $arr);
36+
assertType("array{2, 4, 6}", $result);
37+
}
38+
39+
function testClosureArithmetic(): void
40+
{
41+
$arr = [1, 2, 3];
42+
$result = array_map(function (int $x): int { return $x * 2; }, $arr);
43+
assertType("array{2, 4, 6}", $result);
44+
}
45+
46+
function testArrowFunctionStringConcat(): void
47+
{
48+
$arr = ['a', 'b', 'c'];
49+
$result = array_map(fn(string $s): string => $s . '_suffix', $arr);
50+
assertType("array{'a_suffix', 'b_suffix', 'c_suffix'}", $result);
51+
}
52+
53+
function testNamedFunctionCallback(): void
54+
{
55+
$arr = ['FOO', 'BAR', 'BAZ'];
56+
$result = array_map('strtolower', $arr);
57+
assertType("array{'foo', 'bar', 'baz'}", $result);
58+
}
59+
60+
enum IntEnum: int
61+
{
62+
case A = 10;
63+
case B = 20;
64+
}
65+
66+
function testIntBackedEnum(): void
67+
{
68+
$result = array_map(
69+
static fn (IntEnum $e): int => $e->value,
70+
IntEnum::cases()
71+
);
72+
assertType("array{10, 20}", $result);
73+
}
74+
75+
function testClosureWithStringKeys(): void
76+
{
77+
$arr = ['x' => 1, 'y' => 2];
78+
$result = array_map(fn(int $v): string => (string)$v, $arr);
79+
assertType("array{x: '1', y: '2'}", $result);
80+
}

0 commit comments

Comments
 (0)