Skip to content

Commit 41f4d63

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 41f4d63

15 files changed

Lines changed: 1003 additions & 117 deletions

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

Lines changed: 129 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,15 +34,18 @@
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;
4346
use PHPStan\Type\IntegerType;
4447
use PHPStan\Type\MixedType;
48+
use PHPStan\Type\NeverType;
4549
use PHPStan\Type\NonAcceptingNeverType;
4650
use PHPStan\Type\NullType;
4751
use PHPStan\Type\Type;
@@ -150,6 +154,131 @@ public function getClosureType(
150154
);
151155
}
152156

157+
// once array_reduce()'s TReturn is resolved, the callback is typed by the
158+
// carry it resolved to, like the walk of its body that put that carry in
159+
$arrayReduceArgs = $expr->getAttribute(ArrayReduceArgVisitor::ATTRIBUTE_NAME);
160+
if (
161+
$arrayReduceArgs !== null
162+
&& (
163+
$callableParameters === null
164+
|| !isset($callableParameters[0])
165+
|| $callableParameters[0]->getType()->hasTemplateOrLateResolvableType()
166+
)
167+
) {
168+
return $this->resolveArrayReduceCallbackType($scope, $expr, $storage, $arrayReduceArgs, $parameters, $isVariadic, $contextFree);
169+
}
170+
171+
return $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, $callableParameters, $nativeCallableParameters, $contextFree);
172+
}
173+
174+
/**
175+
* Resolves an array_reduce() callback with a bounded fixed-point iteration
176+
* over its $carry parameter.
177+
*
178+
* The first pass types $carry as the initial argument. When the callback's return
179+
* type escapes it, the carry type is widened and verified in a second pass. When
180+
* even the widened type is not a fixed point, the callback is analysed once more
181+
* with an unknown carry instead of claiming a shape the carry can outgrow.
182+
* See isArrayReduceCarryFixedPoint() for what counts as a fixed point.
183+
*
184+
* @param array{Node\Arg, Node\Arg|null} $arrayReduceArgs
185+
* @param list<NativeParameterReflection> $parameters
186+
*/
187+
private function resolveArrayReduceCallbackType(
188+
MutatingScope $scope,
189+
Node\Expr\Closure|ArrowFunction $expr,
190+
?ExpressionResultStorage $storage,
191+
array $arrayReduceArgs,
192+
array $parameters,
193+
bool $isVariadic,
194+
bool $contextFree,
195+
): ClosureType
196+
{
197+
[$arrayArg, $initialArg] = $arrayReduceArgs;
198+
$itemType = $this->readExprType($storage, $arrayArg->value, $scope, false)->getIterableValueType();
199+
$nativeCallableParameters = self::createArrayReduceCallbackParameters(
200+
new MixedType(),
201+
$this->readExprType($storage, $arrayArg->value, $scope->doNotTreatPhpDocTypesAsCertain(), true)->getIterableValueType(),
202+
);
203+
204+
$carryType = $initialArg !== null ? $this->readExprType($storage, $initialArg->value, $scope, false) : new NullType();
205+
$closureType = $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, self::createArrayReduceCallbackParameters($carryType, $itemType), $nativeCallableParameters, $contextFree);
206+
if (self::isArrayReduceCarryFixedPoint($carryType, $closureType->getReturnType())) {
207+
return $closureType;
208+
}
209+
210+
$carryType = self::widenArrayReduceCarryType($carryType, $closureType->getReturnType());
211+
$closureType = $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, self::createArrayReduceCallbackParameters($carryType, $itemType), $nativeCallableParameters, $contextFree);
212+
if (self::isArrayReduceCarryFixedPoint($carryType, $closureType->getReturnType())) {
213+
return $closureType;
214+
}
215+
216+
return $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, self::createArrayReduceCallbackParameters(new MixedType(), $itemType), $nativeCallableParameters, $contextFree);
217+
}
218+
219+
/**
220+
* Whether the callback returning $returnType keeps the carry within $carryType.
221+
*
222+
* Not isSuperTypeOf(): an array shape without definite sealedness is a supertype
223+
* of the same shape with extra keys, so a carry that grows one key per iteration
224+
* would pass. An implicit never return is the inference giving up on the body,
225+
* not a callback that always throws, so it proves nothing about the carry.
226+
*/
227+
private static function isArrayReduceCarryFixedPoint(Type $carryType, Type $returnType): bool
228+
{
229+
if ($returnType instanceof NeverType && !$returnType->isExplicit()) {
230+
return false;
231+
}
232+
233+
return TypeCombinator::union($carryType, $returnType)->equals($carryType);
234+
}
235+
236+
/**
237+
* @return list<DummyParameter>
238+
*/
239+
private static function createArrayReduceCallbackParameters(Type $carryType, Type $itemType): array
240+
{
241+
return [
242+
new DummyParameter('carry', $carryType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null),
243+
new DummyParameter('item', $itemType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null),
244+
];
245+
}
246+
247+
/** Widens while preserving constant array shapes so that the second pass can converge. */
248+
private static function widenArrayReduceCarryType(Type $initialType, Type $returnType): Type
249+
{
250+
$union = TypeCombinator::union($initialType, $returnType);
251+
$constantArrays = $union->getConstantArrays();
252+
if (count($constantArrays) > 0) {
253+
$generalized = [];
254+
foreach ($constantArrays as $constantArray) {
255+
$generalized[] = $constantArray->generalizeValues();
256+
}
257+
258+
return TypeCombinator::union(...$generalized);
259+
}
260+
261+
return $union->generalize(GeneralizePrecision::lessSpecific());
262+
}
263+
264+
/**
265+
* Walks the body with the given callable parameters, or answers from the
266+
* per-context cache.
267+
*
268+
* @param list<NativeParameterReflection> $parameters
269+
* @param ParameterReflection[]|null $callableParameters
270+
* @param ParameterReflection[]|null $nativeCallableParameters
271+
*/
272+
private function resolveClosureTypeWithParameters(
273+
MutatingScope $scope,
274+
Node\Expr\Closure|ArrowFunction $expr,
275+
array $parameters,
276+
bool $isVariadic,
277+
?array $callableParameters,
278+
?array $nativeCallableParameters,
279+
bool $contextFree,
280+
): ClosureType
281+
{
153282
$cachedTypes = $this->findCachedTypes($expr);
154283
$cacheKey = $this->closureContextCacheKey($scope, $expr, $callableParameters, $parameters);
155284
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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser;
4+
5+
use PHPStan\Testing\TypeInferenceTestCase;
6+
use PHPUnit\Framework\Attributes\DataProvider;
7+
8+
/**
9+
* Without bleeding edge, array shapes have no definite sealedness, and
10+
* array{} is a supertype of array{int} - a growing array_reduce() carry must
11+
* not be mistaken for a fixed point there.
12+
*/
13+
class Bug7280Test extends TypeInferenceTestCase
14+
{
15+
16+
public static function dataFileAsserts(): iterable
17+
{
18+
yield from self::gatherAssertTypes(__DIR__ . '/data/bug-7280-unsealed.php');
19+
}
20+
21+
/**
22+
* @param mixed ...$args
23+
*/
24+
#[DataProvider('dataFileAsserts')]
25+
public function testFileAsserts(
26+
string $assertType,
27+
string $file,
28+
...$args,
29+
): void
30+
{
31+
$this->assertFileAsserts($assertType, $file, ...$args);
32+
}
33+
34+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace Bug7280Unsealed;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* @param list<int> $integers
9+
*/
10+
function listAppend(array $integers): void
11+
{
12+
// the carry grows by one element per item - no array shape is a fixed point
13+
$result = array_reduce($integers, function ($carry, $value) {
14+
$carry[] = $value;
15+
16+
return $carry;
17+
}, []);
18+
assertType('mixed', $result);
19+
20+
$typed = array_reduce($integers, function (array $carry, int $value): array {
21+
assertType('array', $carry);
22+
$carry[] = $value;
23+
24+
return $carry;
25+
}, []);
26+
assertType('array', $typed);
27+
}
28+
29+
/**
30+
* @param list<string> $strings
31+
*/
32+
function doubleAppend(array $strings): void
33+
{
34+
$result = array_reduce($strings, function (array $carry, string $value): array {
35+
$carry[] = $value;
36+
$carry[] = $value;
37+
38+
return $carry;
39+
}, []);
40+
assertType('array', $result);
41+
}

0 commit comments

Comments
 (0)