Skip to content

Commit 5840e4f

Browse files
committed
infer parameters of closures in array arguments
1 parent 8225ce9 commit 5840e4f

7 files changed

Lines changed: 385 additions & 3 deletions

File tree

‎src/Analyser/ExprHandler/ArrowFunctionHandler.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ public function supports(Expr $expr): bool
4141

4242
public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult
4343
{
44-
$arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null);
44+
// an arrow function nested inside a call argument (array literal,
45+
// ternary) carries the parameter type it is passed to - see
46+
// NodeScopeResolver::annotateNestedClosuresWithPassedToType()
47+
[$passedToType, $nativePassedToType] = $expr->getAttribute(NodeScopeResolver::CLOSURE_PASSED_TO_TYPE_ATTRIBUTE) ?? [null, null];
48+
$arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, $passedToType, $nativePassedToType);
4549
$this->closureTypeResolver->seedCacheFromArrowFunctionWalk($scope, $expr, $arrowFunctionResult);
4650
$result = $arrowFunctionResult->getExpressionResult();
4751

‎src/Analyser/ExprHandler/ClosureHandler.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ public function supports(Expr $expr): bool
4141

4242
public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult
4343
{
44-
$processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, null);
44+
// a closure nested inside a call argument (array literal, ternary)
45+
// carries the parameter type it is passed to - see
46+
// NodeScopeResolver::annotateNestedClosuresWithPassedToType()
47+
[$passedToType, $nativePassedToType] = $expr->getAttribute(NodeScopeResolver::CLOSURE_PASSED_TO_TYPE_ATTRIBUTE) ?? [null, null];
48+
$processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, $passedToType, $nativePassedToType);
4549
$this->closureTypeResolver->seedCacheFromClosureWalk($scope, $expr, $processClosureResult);
4650

4751
return $this->expressionResultFactory->create(

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,7 @@ private function buildParametersAndAcceptors(
803803
$nativeCallableParameters = null;
804804
$arrayMapArgs = $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME);
805805
$immediatelyInvokedArgs = $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME);
806+
$passedTo = $expr->getAttribute(NodeScopeResolver::CLOSURE_PASSED_TO_TYPE_ATTRIBUTE);
806807
if ($arrayMapArgs !== null) {
807808
$callableParameters = [];
808809
$nativeCallableParameters = [];
@@ -815,6 +816,13 @@ private function buildParametersAndAcceptors(
815816
$callableParameters[] = new DummyParameter('item', $scope->getType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
816817
$nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
817818
}
819+
} elseif ($passedTo !== null) {
820+
// nested inside a call argument: the projected parameter type, not
821+
// the enclosing parameter on the in-function-call stack (that one
822+
// describes the whole argument, e.g. the array of closures)
823+
[$passedToType, $nativePassedToType] = $passedTo;
824+
$callableParameters = $this->nodeScopeResolver->createCallableParameters($scope, $expr, null, $passedToType);
825+
$nativeCallableParameters = $this->nodeScopeResolver->createNativeCallableParameters($scope, $expr, null, $nativePassedToType ?? $passedToType);
818826
} else {
819827
$inFunctionCallsStackCount = count($scope->inFunctionCallsStack);
820828
if ($inFunctionCallsStackCount > 0) {

‎src/Analyser/NodeScopeResolver.php‎

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
use PHPStan\Reflection\ExtendedMethodReflection;
6666
use PHPStan\Reflection\ExtendedParameterReflection;
6767
use PHPStan\Reflection\FunctionReflection;
68+
use PHPStan\Reflection\InitializerExprTypeResolver;
6869
use PHPStan\Reflection\MethodReflection;
6970
use PHPStan\Reflection\Native\NativeMethodReflection;
7071
use PHPStan\Reflection\Native\NativeParameterReflection;
@@ -77,6 +78,7 @@
7778
use PHPStan\ShouldNotHappenException;
7879
use PHPStan\TrinaryLogic;
7980
use PHPStan\Type\ClosureType;
81+
use PHPStan\Type\Constant\ConstantIntegerType;
8082
use PHPStan\Type\FileTypeMapper;
8183
use PHPStan\Type\FunctionParameterClosureThisExtension;
8284
use PHPStan\Type\FunctionParameterClosureTypeExtension;
@@ -121,6 +123,15 @@ class NodeScopeResolver
121123
public const LOOP_SCOPE_ITERATIONS = 3;
122124
public const GENERALIZE_AFTER_ITERATION = 1;
123125

126+
/**
127+
* Set on a closure/arrow function nested inside a call argument (through
128+
* array literals and ternaries) to the parameter type it ends up passed
129+
* to, as array{Type, Type|null} (phpdoc type, native type).
130+
* A closure that IS the argument gets that type handed to
131+
* processClosureNode() directly and never carries the attribute.
132+
*/
133+
public const CLOSURE_PASSED_TO_TYPE_ATTRIBUTE = 'phpstanClosurePassedToType';
134+
124135
/** @var array<string, true> filePath(string) => bool(true) */
125136
private array $analysedFiles = [];
126137

@@ -1934,12 +1945,19 @@ public function processArgs(
19341945
// args that pin them, so determining siblings are already processed; the mixed pad keeps
19351946
// the argument COUNT correct so the by-ref/variadic variant stays stable (e.g. sscanf),
19361947
// while processed siblings resolve a generic callable(T) parameter. No forward read.
1948+
// An argument that only CONTAINS closures (array literal, ternary) pins template
1949+
// types itself - array<T, Closure(T)> reads T off the keys - so it is padded with
1950+
// its structural type: the literal with each nested closure's declared signature.
19371951
$paddedTypes = [];
19381952
$paddedUnpack = false;
19391953
$paddedHasName = false;
19401954
foreach ($args as $j => $paddedArg) {
19411955
$paddedOriginalArg = $paddedArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $paddedArg;
1942-
$this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, $gatheredArgTypeByIndex[$j] ?? new MixedType());
1956+
$paddedType = $gatheredArgTypeByIndex[$j] ?? null;
1957+
if ($paddedType === null && $j === $i) {
1958+
$paddedType = $this->getStructuralArgType($scope, $arg->value);
1959+
}
1960+
$this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, $paddedType ?? new MixedType());
19431961
}
19441962
$argMetadataAcceptor = $this->selectArgsMetadataAcceptor($args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope);
19451963
} else {
@@ -2178,6 +2196,9 @@ public function processArgs(
21782196
// getType() answers from the stored result
21792197
$this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context);
21802198
} else {
2199+
if ($parameterType !== null) {
2200+
$this->annotateNestedClosuresWithPassedToType($scope, $arg->value, $parameterType, $parameterNativeType);
2201+
}
21812202
$exprType = $scope->getType($arg->value);
21822203
$enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null;
21832204
if ($enterExpressionAssignForByRef) {
@@ -2465,6 +2486,118 @@ private function gatherClosureArgType(array $parametersAcceptors, int $i, Expr $
24652486
return $scope->getType($closureExpr);
24662487
}
24672488

2489+
/**
2490+
* The type of an argument that contains closures, without walking any
2491+
* closure body: array literals and ternaries are built structurally,
2492+
* a nested closure/arrow function contributes its declared signature
2493+
* only, and any other closure-containing expression (a call taking a
2494+
* closure, ...) is mixed. Sub-expressions without a closure inside read
2495+
* their scope type as usual.
2496+
*
2497+
* Null when the expression is not an array literal or ternary - there
2498+
* is nothing structural to read and the caller pads with mixed.
2499+
*/
2500+
private function getStructuralArgType(MutatingScope $scope, Expr $expr): ?Type
2501+
{
2502+
if ($expr instanceof Expr\Ternary) {
2503+
$ifType = $this->getStructuralItemType($scope, $expr->if ?? $expr->cond);
2504+
$elseType = $this->getStructuralItemType($scope, $expr->else);
2505+
2506+
return TypeCombinator::union($ifType, $elseType);
2507+
}
2508+
2509+
if (!$expr instanceof Expr\Array_) {
2510+
return null;
2511+
}
2512+
2513+
return $this->container->getByType(InitializerExprTypeResolver::class)->getArrayType($expr, fn (Expr $itemExpr): Type => $this->getStructuralItemType($scope, $itemExpr));
2514+
}
2515+
2516+
private function getStructuralItemType(MutatingScope $scope, Expr $expr): Type
2517+
{
2518+
if ($expr instanceof Expr\Closure || $expr instanceof Expr\ArrowFunction) {
2519+
return $this->container->getByType(ClosureTypeResolver::class)->getClosureType($scope, $expr, shallow: true);
2520+
}
2521+
2522+
if (!$this->argConsumesResolvedParameterType($expr)) {
2523+
return $scope->getType($expr);
2524+
}
2525+
2526+
return $this->getStructuralArgType($scope, $expr) ?? new MixedType();
2527+
}
2528+
2529+
/**
2530+
* Projects the parameter type an argument is passed to onto the
2531+
* closures/arrow functions nested inside it, so their parameters are
2532+
* inferred the same way as for a closure that IS the argument.
2533+
*
2534+
* Walks array literals (projecting through the key: a literal key or the
2535+
* auto-index reads the offset value type, so array shapes resolve per
2536+
* item) and both branches of a ternary. Any other expression stops the
2537+
* projection - its value is not the argument value itself.
2538+
*/
2539+
private function annotateNestedClosuresWithPassedToType(Scope $scope, Expr $expr, Type $type, ?Type $nativeType): void
2540+
{
2541+
if ($expr instanceof Expr\Closure || $expr instanceof Expr\ArrowFunction) {
2542+
$expr->setAttribute(self::CLOSURE_PASSED_TO_TYPE_ATTRIBUTE, [$type, $nativeType]);
2543+
return;
2544+
}
2545+
2546+
if (!$this->argConsumesResolvedParameterType($expr)) {
2547+
return;
2548+
}
2549+
2550+
if ($expr instanceof Expr\Ternary) {
2551+
if ($expr->if !== null) {
2552+
$this->annotateNestedClosuresWithPassedToType($scope, $expr->if, $type, $nativeType);
2553+
}
2554+
$this->annotateNestedClosuresWithPassedToType($scope, $expr->else, $type, $nativeType);
2555+
return;
2556+
}
2557+
2558+
if (!$expr instanceof Expr\Array_) {
2559+
return;
2560+
}
2561+
2562+
// only the array members of a union describe the literal's items
2563+
if ($type instanceof UnionType) {
2564+
$type = $type->filterTypes(static fn (Type $innerType) => $innerType->isArray()->yes());
2565+
if ($type->isArray()->no()) {
2566+
return;
2567+
}
2568+
}
2569+
if ($nativeType instanceof UnionType) {
2570+
$nativeType = $nativeType->filterTypes(static fn (Type $innerType) => $innerType->isArray()->yes());
2571+
}
2572+
2573+
$nextAutoIndex = 0;
2574+
foreach ($expr->items as $item) {
2575+
if ($item->unpack) {
2576+
// the unpacked count is unknown, later auto-indexes are too
2577+
$nextAutoIndex = null;
2578+
continue;
2579+
}
2580+
2581+
if ($item->key === null) {
2582+
$keyType = $nextAutoIndex !== null ? new ConstantIntegerType($nextAutoIndex++) : null;
2583+
} else {
2584+
$keyType = $scope->getType($item->key);
2585+
$keyValues = $keyType->getConstantScalarValues();
2586+
if ($nextAutoIndex !== null && count($keyValues) === 1 && is_int($keyValues[0])) {
2587+
$nextAutoIndex = max($nextAutoIndex, $keyValues[0] + 1);
2588+
}
2589+
}
2590+
2591+
$itemType = $keyType !== null ? $type->getOffsetValueType($keyType) : $type->getIterableValueType();
2592+
$itemNativeType = null;
2593+
if ($nativeType !== null) {
2594+
$itemNativeType = $keyType !== null ? $nativeType->getOffsetValueType($keyType) : $nativeType->getIterableValueType();
2595+
}
2596+
2597+
$this->annotateNestedClosuresWithPassedToType($scope, $item->value, $itemType, $itemNativeType);
2598+
}
2599+
}
2600+
24682601
/**
24692602
* Whether processing this argument consumes the generic-RESOLVED parameter
24702603
* type: a closure/arrow function does - its parameters and body scope are
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug11215;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class User {}
8+
9+
/** @template TModel */
10+
class Builder
11+
{
12+
}
13+
14+
/** @template TModel */
15+
class Collection
16+
{
17+
/** @param callable(Builder<TModel>): mixed $relation */
18+
public function load($relation): void
19+
{
20+
//
21+
}
22+
23+
/** @param array<string, (callable(Builder<TModel>): mixed)|string> $relations */
24+
public function loadMany($relations): void
25+
{
26+
//
27+
}
28+
}
29+
30+
/** @var Collection<User> $users */
31+
$users->load(function ($query) {
32+
assertType('Bug11215\Builder<Bug11215\User>', $query);
33+
});
34+
35+
$users->loadMany(['foo' => function ($query) {
36+
assertType('Bug11215\Builder<Bug11215\User>', $query);
37+
}]);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug6430;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* @template TKey of array-key
9+
* @template TValue
10+
*/
11+
class HelloWorld
12+
{
13+
/**
14+
* @param array<int, (\Closure(TValue, TKey): mixed)> $callback
15+
*/
16+
public function sayHello($callback): void
17+
{
18+
19+
}
20+
}
21+
22+
/** @var HelloWorld<int, string> */
23+
$a = new HelloWorld;
24+
25+
$a->sayHello([function ($u, $i) {
26+
assertType('string', $u);
27+
assertType('int', $i);
28+
return true;
29+
}]);

0 commit comments

Comments
 (0)