Skip to content

Commit f2f1573

Browse files
ondrejmirtesclaude
andcommitted
Type each invocation of a closure with its own arguments
A closure whose signature is inferred from its invocations joins the arguments of all of them - a closure invoked with Country::class and Region::class returned list<Country|Region> at both calls. Invoked with arguments narrower than the joined parameters, the closure now returns what its body returns for these arguments: the body is typed like a closure passed to a callable taking them, once per distinct signature. The invoked variable holds the closure it is assigned in the body or, for a captured variable, in a body enclosing it, when its type is the type the closure was created with. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
1 parent aa285d9 commit f2f1573

17 files changed

Lines changed: 866 additions & 259 deletions

‎src/Analyser/ExprHandler/FuncCallHandler.php‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use PHPStan\Analyser\ExprHandler\Helper\DynamicReturnTypeStoragePrimer;
2828
use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper;
2929
use PHPStan\Analyser\ExprHandler\Helper\FuncCallScopeEffectsHelper;
30+
use PHPStan\Analyser\Generics\ClosureSignatureInference;
3031
use PHPStan\Analyser\Generics\TemplateArgumentFrame;
3132
use PHPStan\Analyser\ImpurePoint;
3233
use PHPStan\Analyser\InternalThrowPoint;
@@ -52,21 +53,26 @@
5253
use PHPStan\Reflection\Callables\SimpleThrowPoint;
5354
use PHPStan\Reflection\ExtendedParametersAcceptor;
5455
use PHPStan\Reflection\FunctionReflection;
56+
use PHPStan\Reflection\Native\NativeParameterReflection;
5557
use PHPStan\Reflection\ParametersAcceptor;
5658
use PHPStan\Reflection\ParametersAcceptorSelector;
59+
use PHPStan\Reflection\PassedByReference;
5760
use PHPStan\Reflection\ReflectionProvider;
5861
use PHPStan\Rules\Comparison\ImpossibleCheckTypeHelper;
5962
use PHPStan\ShouldNotHappenException;
6063
use PHPStan\Turbo\ShadowedByTurboExtension;
6164
use PHPStan\Type\Accessory\HasPropertyType;
6265
use PHPStan\Type\Accessory\NonEmptyArrayType;
66+
use PHPStan\Type\CallableType;
67+
use PHPStan\Type\ClosureType;
6368
use PHPStan\Type\Constant\ConstantBooleanType;
6469
use PHPStan\Type\DynamicFunctionThrowTypeExtension;
6570
use PHPStan\Type\DynamicReturnTypeExtensionRegistry;
6671
use PHPStan\Type\ErrorType;
6772
use PHPStan\Type\Generic\TemplateTypeHelper;
6873
use PHPStan\Type\Generic\TemplateTypeVariance;
6974
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
75+
use PHPStan\Type\MixedType;
7076
use PHPStan\Type\NeverType;
7177
use PHPStan\Type\ObjectType;
7278
use PHPStan\Type\Type;
@@ -112,6 +118,7 @@ public function __construct(
112118
private ArgumentsHandler $argumentsHandler,
113119
private ClosureProcessor $closureProcessor,
114120
private AssignHandler $assignHandler,
121+
private ClosureSignatureInference $closureSignatureInference,
115122
)
116123
{
117124
}
@@ -341,6 +348,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
341348
}
342349
}
343350
$resolvedParametersAcceptor = $argsResult->getResolvedParametersAcceptor();
351+
if (
352+
$resolvedParametersAcceptor !== null
353+
&& $nameResult !== null
354+
&& $expr->name instanceof Expr\Variable
355+
&& is_string($expr->name->name)
356+
) {
357+
$invokedClosureType = $this->resolveInvokedClosureType($nodeScopeResolver, $normalizedExpr, $expr->name->name, $nameResult->getType(), $argsResult->getScope(), $storage);
358+
if ($invokedClosureType !== null) {
359+
$resolvedParametersAcceptor = $invokedClosureType->getCallableParametersAcceptors($scope)[0];
360+
}
361+
}
344362
// arguments walked ahead of the callee: the scope already carries them
345363
// and the callee walk's own effects (a closure's by-ref uses) on top
346364
if ($argumentsWalkedAhead === null) {
@@ -764,6 +782,63 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin
764782
return TemplateArgumentFrame::returnTypeOfCall($parametersAcceptor, $reflectionScope, $expr);
765783
}
766784

785+
/**
786+
* A closure whose signature is inferred from its invocations (see
787+
* ClosureSignatureInference) joins the arguments of all of them; invoked
788+
* with narrower ones, it returns what its body returns for these. The
789+
* variable holds the closure it was assigned when the type it holds is the
790+
* type the closure was created with.
791+
*/
792+
private function resolveInvokedClosureType(NodeScopeResolver $nodeScopeResolver, FuncCall $call, string $name, Type $calleeType, MutatingScope $scope, ExpressionResultStorage $storage): ?ClosureType
793+
{
794+
if (!$calleeType instanceof ClosureType || !$this->closureSignatureInference->infersInvocationReturnType($scope, $calleeType)) {
795+
return null;
796+
}
797+
$parameters = $calleeType->getParameters();
798+
$args = $call->getArgs();
799+
if ($args === [] || count($args) > count($parameters)) {
800+
return null;
801+
}
802+
803+
$narrower = false;
804+
$callableParameters = [];
805+
foreach ($parameters as $i => $parameter) {
806+
if ($parameter->isVariadic() || !$parameter->passedByReference()->no()) {
807+
return null;
808+
}
809+
$type = $parameter->getType();
810+
if (isset($args[$i])) {
811+
if ($args[$i]->name !== null || $args[$i]->unpack) {
812+
return null;
813+
}
814+
$argumentType = $nodeScopeResolver->readTypeOfMaybeStored($args[$i]->value, $scope);
815+
// an argument the signature rejects is reported against it
816+
if (!$type->isSuperTypeOf($argumentType)->yes()) {
817+
return null;
818+
}
819+
if (!$argumentType->isSuperTypeOf($type)->yes()) {
820+
$narrower = true;
821+
$type = $argumentType;
822+
}
823+
}
824+
$callableParameters[] = new NativeParameterReflection($parameter->getName(), $parameter->isOptional(), $type, PassedByReference::createNo(), false, null);
825+
}
826+
if (!$narrower) {
827+
return null;
828+
}
829+
830+
foreach ($this->closureSignatureInference->findAssignedClosures($scope, $name) as $closure) {
831+
$creationResult = $storage->findExpressionResult($closure);
832+
if ($creationResult === null || !$creationResult->getType()->equals($calleeType)) {
833+
continue;
834+
}
835+
836+
return $this->closureTypeResolver->getClosureType($creationResult->getBeforeScope(), $closure, false, $storage, new CallableType($callableParameters, new MixedType(), false));
837+
}
838+
839+
return null;
840+
}
841+
767842
/**
768843
* Ported inside-out from the old TypeResolvingExprHandler::specifyTypes(): the
769844
* FunctionTypeSpecifyingExtensions, conditional-return-type and @phpstan-assert

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,19 @@ private function findCachedTypes(Node\Expr\Closure|ArrowFunction $expr): array
120120
* the closure-as-call-arg store sites) feed the gathered returns/yields to
121121
* buildClosureType() instead, which constructs the same ClosureType without
122122
* a second walk.
123+
*
124+
* $passedToType types the parameters like a callable parameter the closure
125+
* is passed to does - FuncCallHandler passes the arguments of an invocation.
123126
*/
124127
public function getClosureType(
125128
MutatingScope $scope,
126129
Node\Expr\Closure|ArrowFunction $expr,
127130
bool $shallow = false,
128131
?ExpressionResultStorage $storage = null,
132+
?Type $passedToType = null,
129133
): ClosureType
130134
{
131-
[$parameters, $isVariadic, $callableParameters, $nativeCallableParameters, $contextFree] = $this->buildParametersAndAcceptors($scope, $expr, $storage);
135+
[$parameters, $isVariadic, $callableParameters, $nativeCallableParameters, $contextFree] = $this->buildParametersAndAcceptors($scope, $expr, $storage, $passedToType);
132136

133137
// A shallow reflection is the closure/arrow function's signature without
134138
// walking its body: parameters plus the DECLARED return type. Used at scope

‎src/Analyser/Generics/ClosureSignatureInference.php‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
use PHPStan\Reflection\Native\NativeParameterReflection;
1313
use PHPStan\Reflection\PassedByReference;
1414
use PHPStan\Turbo\ShadowedByTurboExtension;
15+
use PHPStan\Type\ClosureType;
1516
use PHPStan\Type\Generic\TemplateTypeFactory;
1617
use PHPStan\Type\Generic\TemplateTypeScope;
1718
use PHPStan\Type\Generic\TemplateTypeVariance;
1819
use PHPStan\Type\Generic\UnresolvedTemplateArgumentType;
1920
use PHPStan\Type\MixedType;
2021
use PHPStan\Type\Type;
22+
use PHPStan\Type\TypeTraverser;
2123
use function array_keys;
2224
use function array_pop;
2325
use function count;
@@ -55,6 +57,8 @@ final class ClosureSignatureInference
5557

5658
private const CLOSED_BODY_ATTRIBUTE = 'closureSignatureClosedBody';
5759

60+
private const ASSIGNED_CLOSURES_ATTRIBUTE = 'closureSignatureAssignedClosures';
61+
5862
public function __construct(
5963
#[AutowiredParameter(ref: '%featureToggles.closureSignaturesFromUsages%')]
6064
private bool $enabled,
@@ -454,6 +458,103 @@ private static function scanClosedBody(Node $functionLike, array $stmts): bool
454458
return true;
455459
}
456460

461+
/**
462+
* Whether an invocation of the closure returns what the body returns for
463+
* its arguments (see FuncCallHandler): its signature is resolved - a body
464+
* walked once, with no sites of its own, invokes the resolved closures of
465+
* the body it is written in.
466+
*/
467+
public function infersInvocationReturnType(MutatingScope $scope, ClosureType $closureType): bool
468+
{
469+
if (!$this->enabled || $scope->getCurrentTemplateArgumentFrame() === null) {
470+
return false;
471+
}
472+
473+
$containsMarker = false;
474+
TypeTraverser::map($closureType, static function (Type $type, callable $traverse) use (&$containsMarker): Type {
475+
if ($type instanceof UnresolvedTemplateArgumentType) {
476+
$containsMarker = true;
477+
}
478+
479+
return $containsMarker ? $type : $traverse($type);
480+
});
481+
482+
return !$containsMarker;
483+
}
484+
485+
/**
486+
* The closures and arrow functions assigned to the local variable in the
487+
* body being walked and - a variable a closure captures - in the bodies
488+
* enclosing it.
489+
*
490+
* @return list<Closure|ArrowFunction>
491+
*/
492+
public function findAssignedClosures(MutatingScope $scope, string $name): array
493+
{
494+
if (!$this->enabled) {
495+
return [];
496+
}
497+
498+
$closures = [];
499+
for ($frame = $scope->getCurrentTemplateArgumentFrame(); $frame !== null; $frame = $frame->getParent()) {
500+
$body = $frame->getClosureSignatureBody();
501+
if ($body === null) {
502+
continue;
503+
}
504+
foreach (self::getAssignedClosures($body, $frame->getClosureSignatureStmts())[$name] ?? [] as $closure) {
505+
$closures[] = $closure;
506+
}
507+
}
508+
509+
return $closures;
510+
}
511+
512+
/**
513+
* @param Node\Stmt[] $stmts
514+
* @return array<string, list<Closure|ArrowFunction>>
515+
*/
516+
private static function getAssignedClosures(Node $body, array $stmts): array
517+
{
518+
/** @var array<string, list<Closure|ArrowFunction>>|null $cached */
519+
$cached = $body->getAttribute(self::ASSIGNED_CLOSURES_ATTRIBUTE);
520+
if ($cached !== null) {
521+
return $cached;
522+
}
523+
524+
$assigned = [];
525+
$stack = $stmts;
526+
while (count($stack) > 0) {
527+
$node = array_pop($stack);
528+
if ($node instanceof Node\FunctionLike || $node instanceof Node\Stmt\ClassLike) {
529+
continue;
530+
}
531+
if (
532+
$node instanceof Expr\Assign
533+
&& $node->var instanceof Expr\Variable
534+
&& is_string($node->var->name)
535+
&& ($node->expr instanceof Closure || $node->expr instanceof ArrowFunction)
536+
) {
537+
$assigned[$node->var->name][] = $node->expr;
538+
}
539+
foreach ($node->getSubNodeNames() as $subNodeName) {
540+
$subNode = $node->$subNodeName;
541+
if ($subNode instanceof Node) {
542+
$stack[] = $subNode;
543+
} elseif (is_array($subNode)) {
544+
foreach ($subNode as $item) {
545+
if (!$item instanceof Node) {
546+
continue;
547+
}
548+
$stack[] = $item;
549+
}
550+
}
551+
}
552+
}
553+
$body->setAttribute(self::ASSIGNED_CLOSURES_ATTRIBUTE, $assigned);
554+
555+
return $assigned;
556+
}
557+
457558
/**
458559
* @param array<string, true> $names
459560
*/

‎src/Analyser/Generics/TemplateArgumentFrame.php‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ public function isSettledClosureSite(Expr $site): bool
111111
return $this->parent !== null && $this->parent->isSettledClosureSite($site);
112112
}
113113

114+
/** The frame of the body this frame's body is written in. */
115+
public function getParent(): ?self
116+
{
117+
return $this->parent;
118+
}
119+
114120
/**
115121
* The function-like body the frame observes, asked only when a closure in
116122
* it has its signature inferred (see ClosureSignatureInference::isClosedBody()).

‎src/Analyser/Generics/TemplateArgumentResolver.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ public function resolve(TemplateArgumentConstraints $constraints, ?TemplateArgum
6464
TemplateArgumentStats::increment('closureSitesSettled', count($settledClosureSites));
6565
}
6666

67-
return new TemplateArgumentFrame($parent, $resolutions, $this->collectSiteStatementIndexes($siteIndexes, $settledClosureSites), settledClosureSites: $settledClosureSites);
67+
return new TemplateArgumentFrame($parent, $resolutions, $this->collectSiteStatementIndexes($siteIndexes, $settledClosureSites), $closureSignatureBody, $closureSignatureStmts, $settledClosureSites);
6868
}
6969

7070
/**

‎tests/PHPStan/Analyser/nsrt/closure-signature-from-usages-generics.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,8 @@ public function closureCreatesGenericObject(): void
139139
assertType("1|'a'", $v);
140140
return new Box($v);
141141
};
142-
assertType("ClosureSignatureFromUsagesGenerics\Box<1|'a'>", $make(1));
143-
assertType("ClosureSignatureFromUsagesGenerics\Box<1|'a'>", $make('a'));
142+
assertType('ClosureSignatureFromUsagesGenerics\Box<1>', $make(1));
143+
assertType("ClosureSignatureFromUsagesGenerics\Box<'a'>", $make('a'));
144144
}
145145

146146
public function closureFeedsGenericObject(): void

‎tests/PHPStan/Analyser/nsrt/closure-signature-from-usages.php‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,26 @@ function takesMixed(mixed $m): void
3838
{
3939
}
4040

41+
class InvokedCountry
42+
{
43+
44+
public static function tryFrom(mixed $value): ?self
45+
{
46+
return null;
47+
}
48+
49+
}
50+
51+
class InvokedRegion
52+
{
53+
54+
public static function tryFrom(mixed $value): ?self
55+
{
56+
return null;
57+
}
58+
59+
}
60+
4161
/**
4262
* @template T of \BackedEnum|int|string
4363
* @param \Closure(T): string $cb
@@ -100,7 +120,7 @@ public function arrowFunction(): void
100120
{
101121
$f = fn ($a) => $a;
102122
$f(1);
103-
assertType("1|'x'", $f('x'));
123+
assertType("'x'", $f('x'));
104124
}
105125

106126
public function arrayOffsets(): void
@@ -515,4 +535,35 @@ public function variadicParameterKeepsDeclaredType(): void
515535
$outer('x', 1.0);
516536
}
517537

538+
public function invocationReturnsWhatTheBodyReturnsForItsArguments(mixed $a, mixed $b): void
539+
{
540+
$toEnumList = static function (mixed $value, string $enumClassName): array {
541+
assertType("'ClosureSignatureFromUsages\\\\InvokedCountry'|'ClosureSignatureFromUsages\\\\InvokedRegion'", $enumClassName);
542+
$enumValues = [];
543+
foreach ((array) $value as $val) {
544+
$enumValue = $enumClassName::tryFrom($val);
545+
if ($enumValue !== null) {
546+
$enumValues[] = $enumValue;
547+
}
548+
}
549+
return $enumValues;
550+
};
551+
assertType('list<ClosureSignatureFromUsages\\InvokedCountry>', $toEnumList($a, InvokedCountry::class));
552+
assertType('list<ClosureSignatureFromUsages\\InvokedRegion>', $toEnumList($b, InvokedRegion::class));
553+
554+
$inner = static function (mixed $value) use ($toEnumList): void {
555+
assertType('array{}|array{ClosureSignatureFromUsages\\InvokedCountry}', $toEnumList($value, InvokedCountry::class));
556+
};
557+
$inner(1);
558+
}
559+
560+
public function invocationOfAReassignedVariable(): void
561+
{
562+
$f = static fn (int|string $x): int|string => $x;
563+
assertType('1', $f(1));
564+
$f = static fn (int|string $x): string => 'other';
565+
assertType("'other'", $f(1));
566+
$f('a');
567+
}
568+
518569
}

0 commit comments

Comments
 (0)