Skip to content

Commit 125331d

Browse files
gnutixclaude
andcommitted
Infer array_reduce() callback parameter types from the initial and array arguments
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QW8VaDtn3cMdQowPSh1xGG
1 parent 184d34e commit 125331d

11 files changed

Lines changed: 798 additions & 117 deletions

File tree

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use PHPStan\Node\ExecutionEndNode;
2626
use PHPStan\Node\InvalidateExprNode;
2727
use PHPStan\Node\PropertyAssignNode;
28+
use PHPStan\Parser\ArrayReduceArgVisitor;
2829
use PHPStan\Reflection\Callables\SimpleImpurePoint;
2930
use PHPStan\Reflection\Callables\SimpleThrowPoint;
3031
use PHPStan\Reflection\ExtendedParameterReflection;
@@ -33,10 +34,12 @@
3334
use PHPStan\Reflection\Native\NativeParameterReflection;
3435
use PHPStan\Reflection\ParameterReflection;
3536
use PHPStan\Reflection\PassedByReference;
37+
use PHPStan\Reflection\Php\DummyParameter;
3638
use PHPStan\ShouldNotHappenException;
3739
use PHPStan\TrinaryLogic;
3840
use PHPStan\Turbo\ShadowedByTurboExtension;
3941
use PHPStan\Type\ClosureType;
42+
use PHPStan\Type\GeneralizePrecision;
4043
use PHPStan\Type\Generic\GenericObjectType;
4144
use PHPStan\Type\Generic\TemplateTypeMap;
4245
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
@@ -150,6 +153,104 @@ public function getClosureType(
150153
);
151154
}
152155

156+
$arrayReduceArgs = $expr->getAttribute(ArrayReduceArgVisitor::ATTRIBUTE_NAME);
157+
if ($arrayReduceArgs !== null) {
158+
return $this->resolveArrayReduceCallbackType($scope, $expr, $storage, $arrayReduceArgs, $parameters, $isVariadic, $contextFree);
159+
}
160+
161+
return $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, $callableParameters, $nativeCallableParameters, $contextFree);
162+
}
163+
164+
/**
165+
* Resolves an array_reduce() callback with a bounded fixed-point iteration
166+
* over its $carry parameter.
167+
*
168+
* The first pass types $carry as the initial argument. When the callback's return
169+
* type escapes it, the carry type is widened and verified in a second pass. When
170+
* even the widened type is not a fixed point, the callback is analysed once more
171+
* with an unknown carry instead of claiming a shape the carry can outgrow.
172+
*
173+
* @param array{Node\Arg, Node\Arg|null} $arrayReduceArgs
174+
* @param list<NativeParameterReflection> $parameters
175+
*/
176+
private function resolveArrayReduceCallbackType(
177+
MutatingScope $scope,
178+
Node\Expr\Closure|ArrowFunction $expr,
179+
?ExpressionResultStorage $storage,
180+
array $arrayReduceArgs,
181+
array $parameters,
182+
bool $isVariadic,
183+
bool $contextFree,
184+
): ClosureType
185+
{
186+
[$arrayArg, $initialArg] = $arrayReduceArgs;
187+
$itemType = $this->readExprType($storage, $arrayArg->value, $scope, false)->getIterableValueType();
188+
$nativeCallableParameters = self::createArrayReduceCallbackParameters(
189+
new MixedType(),
190+
$this->readExprType($storage, $arrayArg->value, $scope->doNotTreatPhpDocTypesAsCertain(), true)->getIterableValueType(),
191+
);
192+
193+
$carryType = $initialArg !== null ? $this->readExprType($storage, $initialArg->value, $scope, false) : new NullType();
194+
$closureType = $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, self::createArrayReduceCallbackParameters($carryType, $itemType), $nativeCallableParameters, $contextFree);
195+
if ($carryType->isSuperTypeOf($closureType->getReturnType())->yes()) {
196+
return $closureType;
197+
}
198+
199+
$carryType = self::widenArrayReduceCarryType($carryType, $closureType->getReturnType());
200+
$closureType = $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, self::createArrayReduceCallbackParameters($carryType, $itemType), $nativeCallableParameters, $contextFree);
201+
if ($carryType->isSuperTypeOf($closureType->getReturnType())->yes()) {
202+
return $closureType;
203+
}
204+
205+
return $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, self::createArrayReduceCallbackParameters(new MixedType(), $itemType), $nativeCallableParameters, $contextFree);
206+
}
207+
208+
/**
209+
* @return list<DummyParameter>
210+
*/
211+
private static function createArrayReduceCallbackParameters(Type $carryType, Type $itemType): array
212+
{
213+
return [
214+
new DummyParameter('carry', $carryType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null),
215+
new DummyParameter('item', $itemType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null),
216+
];
217+
}
218+
219+
/** Widens while preserving constant array shapes so that the second pass can converge. */
220+
private static function widenArrayReduceCarryType(Type $initialType, Type $returnType): Type
221+
{
222+
$union = TypeCombinator::union($initialType, $returnType);
223+
$constantArrays = $union->getConstantArrays();
224+
if (count($constantArrays) > 0) {
225+
$generalized = [];
226+
foreach ($constantArrays as $constantArray) {
227+
$generalized[] = $constantArray->generalizeValues();
228+
}
229+
230+
return TypeCombinator::union(...$generalized);
231+
}
232+
233+
return $union->generalize(GeneralizePrecision::lessSpecific());
234+
}
235+
236+
/**
237+
* Walks the body with the given callable parameters, or answers from the
238+
* per-context cache.
239+
*
240+
* @param list<NativeParameterReflection> $parameters
241+
* @param ParameterReflection[]|null $callableParameters
242+
* @param ParameterReflection[]|null $nativeCallableParameters
243+
*/
244+
private function resolveClosureTypeWithParameters(
245+
MutatingScope $scope,
246+
Node\Expr\Closure|ArrowFunction $expr,
247+
array $parameters,
248+
bool $isVariadic,
249+
?array $callableParameters,
250+
?array $nativeCallableParameters,
251+
bool $contextFree,
252+
): ClosureType
253+
{
153254
$cachedTypes = $this->findCachedTypes($expr);
154255
$cacheKey = $this->closureContextCacheKey($scope, $expr, $callableParameters, $parameters);
155256
if (array_key_exists($cacheKey, $cachedTypes)) {

‎src/Analyser/ScopeOps.php‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use PHPStan\Node\Printer\ExprPrinter;
1616
use PHPStan\Node\VirtualNode;
1717
use PHPStan\Parser\ArrayMapArgVisitor;
18+
use PHPStan\Parser\ArrayReduceArgVisitor;
1819
use PHPStan\Reflection\ClassReflection;
1920
use PHPStan\Reflection\FunctionReflection;
2021
use PHPStan\Reflection\MethodReflection;
@@ -71,11 +72,14 @@ public static function nodeKey(Expr $node, ExprPrinter $exprPrinter): string
7172
$attributes = $node->getAttributes();
7273
if (
7374
$node instanceof Node\FunctionLike
74-
&& (($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] ?? null) !== null)
75+
&& (
76+
(($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] ?? null) !== null)
77+
|| (($attributes[ArrayReduceArgVisitor::ATTRIBUTE_NAME] ?? null) !== null)
78+
)
7579
&& (($attributes['startFilePos'] ?? null) !== null)
7680
) {
7781
$key .= '/*' . $attributes['startFilePos'];
78-
foreach ($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] as $arg) {
82+
foreach ($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] ?? [] as $arg) {
7983
$key .= ':' . $exprPrinter->printExpr($arg->value);
8084
}
8185
$key .= '*/';
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Parser;
4+
5+
use Override;
6+
use PhpParser\Node;
7+
use PhpParser\NodeVisitorAbstract;
8+
use PHPStan\DependencyInjection\AutowiredService;
9+
use PHPStan\Turbo\ShadowedByTurboExtension;
10+
11+
#[AutowiredService]
12+
#[ShadowedByTurboExtension(implementation: __DIR__ . '/../../turbo-ext/src/ArrayReduceArgVisitor.cpp')]
13+
final class ArrayReduceArgVisitor extends NodeVisitorAbstract
14+
{
15+
16+
public const ATTRIBUTE_NAME = 'arrayReduceArgs';
17+
18+
#[Override]
19+
public function enterNode(Node $node): ?Node
20+
{
21+
if ($node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Name && !$node->isFirstClassCallable()) {
22+
$functionName = $node->name->toLowerString();
23+
if ($functionName === 'array_reduce') {
24+
$arrayArg = null;
25+
$callbackArg = null;
26+
$initialArg = null;
27+
foreach ($node->getArgs() as $i => $arg) {
28+
if ($arg->unpack) {
29+
return null;
30+
}
31+
$name = $arg->name !== null ? $arg->name->toString() : null;
32+
if ($name === 'array' || ($name === null && $i === 0)) {
33+
$arrayArg = $arg;
34+
} elseif ($name === 'callback' || ($name === null && $i === 1)) {
35+
$callbackArg = $arg;
36+
} elseif ($name === 'initial' || ($name === null && $i === 2)) {
37+
$initialArg = $arg;
38+
}
39+
}
40+
if ($arrayArg !== null && $callbackArg !== null) {
41+
$callbackArg->value->setAttribute(self::ATTRIBUTE_NAME, [$arrayArg, $initialArg]);
42+
}
43+
}
44+
}
45+
return null;
46+
}
47+
48+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php // lint >= 8.1
2+
3+
namespace Bug7280;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function namedArguments(): void
8+
{
9+
$result = array_reduce(
10+
['test1', 'test2'],
11+
static function (array $carry, string $value): array{
12+
assertType('array{starts: array{}, ends: array{}}|array{starts: non-empty-list<string>, ends: non-empty-list<string>}', $carry);
13+
$carry['starts'][] = $value;
14+
$carry['ends'][] = $value;
15+
16+
return $carry;
17+
},
18+
initial: ['starts' => [], 'ends' => []],
19+
);
20+
assertType('array{starts: non-empty-list<string>, ends: non-empty-list<string>}', $result);
21+
}
22+
23+
function positionalArguments(): void
24+
{
25+
$result = array_reduce(
26+
['test1', 'test2'],
27+
static function (array $carry, string $value): array{
28+
assertType('array{starts: array{}, ends: array{}}|array{starts: non-empty-list<string>, ends: non-empty-list<string>}', $carry);
29+
$carry['starts'][] = $value;
30+
$carry['ends'][] = $value;
31+
32+
return $carry;
33+
},
34+
['starts' => [], 'ends' => []],
35+
);
36+
assertType('array{starts: non-empty-list<string>, ends: non-empty-list<string>}', $result);
37+
}
38+
39+
function reducer(?int $carry, int $item): int
40+
{
41+
return ($carry ?? 0) + $item;
42+
}
43+
44+
/**
45+
* @param list<int> $integers
46+
*/
47+
function firstClassCallable(array $integers): void
48+
{
49+
$result = array_reduce($integers, reducer(...), 0);
50+
assertType('int', $result);
51+
}
52+
53+
/**
54+
* @param int[] $integers
55+
*/
56+
function nonConvergingCallback(array $integers): void
57+
{
58+
// the carry type never reaches a fixed point here - the callback is analysed with an unknown carry
59+
$result = array_reduce($integers, function ($carry, $value) {
60+
assertType('array{}|array{x: mixed}', $carry);
61+
62+
return ['x' => $carry];
63+
}, []);
64+
assertType('array{}|array{x: array{}|array{x: mixed}}', $result);
65+
66+
$result2 = array_reduce($integers, fn ($carry, $value) => ['x' => $carry], []);
67+
assertType('array{}|array{x: array{}|array{x: mixed}}', $result2);
68+
}
69+
70+
/**
71+
* @param array<string> $strings
72+
*/
73+
function arrowFunction(array $strings): void
74+
{
75+
$result = array_reduce($strings, fn (int $carry, string $value): int => $carry + strlen($value), 0);
76+
assertType('int', $result);
77+
}
78+
79+
/**
80+
* @param int[] $integers
81+
*/
82+
function intSum(array $integers): void
83+
{
84+
$sum = array_reduce($integers, function ($carry, $n) {
85+
assertType('int', $carry);
86+
87+
return $carry + $n;
88+
}, 0);
89+
assertType('int', $sum);
90+
}
91+
92+
/**
93+
* @param array<string> $strings
94+
*/
95+
function stringConcat(array $strings): void
96+
{
97+
$concatenated = array_reduce($strings, function (string $carry, string $string): string {
98+
assertType('string', $carry);
99+
100+
return $carry . $string;
101+
}, '');
102+
assertType('string', $concatenated);
103+
}
104+
105+
/**
106+
* @param array<string> $strings
107+
*/
108+
function withoutInitial(array $strings): void
109+
{
110+
$concatenated = array_reduce($strings, function ($carry, string $string) {
111+
assertType('string|null', $carry);
112+
113+
return ($carry ?? '') . $string;
114+
});
115+
assertType('string|null', $concatenated);
116+
}

0 commit comments

Comments
 (0)