Skip to content

Commit 596dd95

Browse files
phpstan-botclaude
authored andcommitted
Use the enclosing body's send-decided template argument resolutions inside nested closure bodies
- TemplateArgumentFrame: record which resolutions a send decided (sentResolutionKeys, filled by TemplateArgumentResolver::collectSentResolutionKeys()) and expose them via resolveSentFromEnclosingBody(), which walks up the parent frames - ResolvedFunctionVariantWithOriginal::unresolvedOrResolvedTemplateArgument(): while a closure body observes, reuse an enclosing body's send-decided resolution of the call site instead of creating a fresh marker that the closure's own pass resolves to the literal initial type (method, function and static calls) - NewHandler (unresolvedArgumentList(), rekeyParentTemplateArgument()) and GenericTypeTemplateTraverser: same for `new` sites - constructor-inferred arguments, classes without a constructor, and arguments inherited from a parent constructor - ClosureTypeResolver::getClosureType(): walk a closure body in a single pass while the enclosing body is observing, so its markers reach the enclosing body (the generic call taking the closure, e.g. run(function () { ... })) instead of being resolved by the closure's own pass before the enclosing body could see the send that decides them - Only send-decided resolutions are reused: an argument the enclosing body merely inferred (e.g. from a closure parameter, closureCreatesGenericObject) stays with the closure's own walk, which infers it per invocation - Ported to the turbo extension: TemplateArgumentFrame.cpp, TemplateArgumentResolver.cpp, ResolvedFunctionVariantWithOriginal.cpp, NewHandler.cpp, ClosureTypeResolver.cpp, support.h, regenerated TemplateArgumentFrame.h / TemplateArgumentResolver.h - Probed and already correct: arrow functions (part of the enclosing body), nested closures inside arrow functions, closures stored in a variable inside a closure Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4ef9ab7 commit 596dd95

17 files changed

Lines changed: 642 additions & 111 deletions

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu
229229
$closureImpurePoints = [];
230230
$invalidateExpressions = [];
231231

232+
// a body walked while the enclosing body observes keeps its unresolved
233+
// template arguments for the enclosing body to resolve
234+
$resolveTemplateArguments = $scope->getCurrentTemplateArgumentFrame()?->isObserving() !== true;
232235
try {
233236
$walkStorage = new ExpressionResultStorage();
234237
$closureScope->pushExpressionResultStorage($walkStorage);
@@ -268,7 +271,7 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu
268271
}
269272

270273
$closureYieldStatements[] = [$node, $scope];
271-
}, StatementContext::createTopLevel())->toPublic();
274+
}, StatementContext::createTopLevel($resolveTemplateArguments))->toPublic();
272275
} finally {
273276
$closureScope->popExpressionResultStorage();
274277
self::$resolveClosureTypeDepth--;

‎src/Analyser/ExprHandler/NewHandler.php‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -783,9 +783,13 @@ private function unresolvedArgumentList(ClassReflection $classReflection, New_ $
783783
if (!$type instanceof TemplateType) {
784784
return $type;
785785
}
786-
if ($synthetic || ($allowUnresolved && $frame->isObserving())) {
786+
if ($synthetic) {
787787
return new UnresolvedTemplateArgumentType($site, $type, null);
788788
}
789+
if ($allowUnresolved && $frame->isObserving()) {
790+
// a send in a body enclosing this one decided the site
791+
return $frame->resolveSentFromEnclosingBody($site, $type->getName()) ?? new UnresolvedTemplateArgumentType($site, $type, null);
792+
}
789793

790794
return $frame->resolveOrUnconstrained($site, $type);
791795
}));
@@ -797,7 +801,8 @@ private function rekeyParentTemplateArgument(UnresolvedTemplateArgumentType $typ
797801
return $type->getInitialType() ?? $template->getDefault() ?? $template->getBound();
798802
}
799803
if ($allowUnresolved && $frame->isObserving()) {
800-
return $type->withSite($site, $template);
804+
// a send in a body enclosing this one decided the site
805+
return $frame->resolveSentFromEnclosingBody($site, $template->getName()) ?? $type->withSite($site, $template);
801806
}
802807

803808
return $frame->resolve($site, $template->getName()) ?? $type->getInitialType() ?? $frame->resolveOrUnconstrained($site, $template);

‎src/Analyser/Generics/TemplateArgumentFrame.php‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ public static function returnTypeOfCall(ParametersAcceptor $acceptor, MutatingSc
6868
* @param array<int, array{Expr\Closure, int, bool}> $byRefSites spl_object_id() of the closure node => the node, its statement index, whether every invocation was seen
6969
* @param array<int, array{Expr\Variable, Type, Type}> $staticVariableTypes spl_object_id() of a `static` variable node => the node, its phpdoc and native type - see StaticVariableInference
7070
* @param array<int, array{Node\Stmt\Static_, array<string, ConditionalExpressionHolder[]>}> $staticVariableConditionalExpressions spl_object_id() of the last statement of a run of `static` statements => the statement, the conditional expressions between the run's variables - see StaticVariableInference::getRuns()
71+
* @param array<string, true> $sentResolutionKeys the keys of the resolutions a send decided - see resolveSentFromEnclosingBody()
7172
*/
7273
public function __construct(
7374
private readonly ?self $parent,
@@ -80,6 +81,7 @@ public function __construct(
8081
private readonly array $byRefSites = [],
8182
private readonly array $staticVariableTypes = [],
8283
private readonly array $staticVariableConditionalExpressions = [],
84+
private readonly array $sentResolutionKeys = [],
8385
)
8486
{
8587
}
@@ -131,6 +133,7 @@ public function withStaticVariableTypes(array $staticVariableTypes, array $state
131133
$this->byRefSites,
132134
$staticVariableTypes,
133135
$conditionalExpressions,
136+
$this->sentResolutionKeys,
134137
);
135138
}
136139

@@ -190,6 +193,7 @@ public function withObservedClosures(array $closureResolutions, array $closureSi
190193
byRefSites: $byRefSites,
191194
staticVariableTypes: $this->staticVariableTypes,
192195
staticVariableConditionalExpressions: $this->staticVariableConditionalExpressions,
196+
sentResolutionKeys: $this->sentResolutionKeys,
193197
);
194198
}
195199

@@ -331,6 +335,26 @@ public function resolve(Expr $site, string $templateName): ?Type
331335
return null;
332336
}
333337

338+
/**
339+
* The resolution a body enclosing this one gave a site of a closure body it
340+
* encloses, when a send in it decided the argument - the closure's own walk
341+
* cannot see that send. An argument the enclosing body merely inferred is
342+
* left to the closure's walk, which infers it from its own state.
343+
*/
344+
public function resolveSentFromEnclosingBody(Expr $site, string $templateName): ?Type
345+
{
346+
$key = self::key($site, $templateName);
347+
if (isset($this->sentResolutionKeys[$key], $this->resolutions[$key])) {
348+
return $this->resolutions[$key];
349+
}
350+
351+
if ($this->parent !== null) {
352+
return $this->parent->resolveSentFromEnclosingBody($site, $templateName);
353+
}
354+
355+
return null;
356+
}
357+
334358
/**
335359
* Distinguishes cache entries computed while observing from those computed
336360
* with the resolutions installed (the closure type cache keys on scope state

‎src/Analyser/Generics/TemplateArgumentResolver.php‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,14 @@ public function resolve(TemplateArgumentConstraints $constraints, ?TemplateArgum
5858
TemplateArgumentStats::increment('closureObservationPasses');
5959
}
6060

61-
return new TemplateArgumentFrame($parent, array_diff_key($resolutions, $closureKeys), $templateSiteStatementIndexes, $closureSignatureBody, $closureSignatureStmts, observingClosures: true);
61+
return new TemplateArgumentFrame($parent, array_diff_key($resolutions, $closureKeys), $templateSiteStatementIndexes, $closureSignatureBody, $closureSignatureStmts, observingClosures: true, sentResolutionKeys: $this->collectSentResolutionKeys($observations));
6262
}
6363

6464
if (TemplateArgumentStats::$enabled && count($settledClosureSites) > 0) {
6565
TemplateArgumentStats::increment('closureSitesSettled', count($settledClosureSites));
6666
}
6767

68-
return new TemplateArgumentFrame($parent, $resolutions, $this->collectSiteStatementIndexes($siteIndexes, $settledClosureSites), $closureSignatureBody, $closureSignatureStmts, $settledClosureSites, byRefSites: $this->collectByRefSites($observations, $siteIndexes));
68+
return new TemplateArgumentFrame($parent, $resolutions, $this->collectSiteStatementIndexes($siteIndexes, $settledClosureSites), $closureSignatureBody, $closureSignatureStmts, $settledClosureSites, byRefSites: $this->collectByRefSites($observations, $siteIndexes), sentResolutionKeys: $this->collectSentResolutionKeys($observations));
6969
}
7070

7171
/**
@@ -225,6 +225,23 @@ private function collectByRefSites(array $observations, array $siteIndexes): arr
225225
return $byRefSites;
226226
}
227227

228+
/**
229+
* @param array<string, array{marker: UnresolvedTemplateArgumentType, initial: Type|null, sends: list<array{Type, TemplateTypeVariance}>, lowerBounds: list<Type>, unconstrainingSend: bool}> $observations
230+
* @return array<string, true>
231+
*/
232+
private function collectSentResolutionKeys(array $observations): array
233+
{
234+
$keys = [];
235+
foreach ($observations as $key => $observation) {
236+
if ($observation['sends'] === [] || ClosureSignatureInference::isClosureSignatureMarker($observation['marker'])) {
237+
continue;
238+
}
239+
$keys[$key] = true;
240+
}
241+
242+
return $keys;
243+
}
244+
228245
/**
229246
* @param array<string, array{marker: UnresolvedTemplateArgumentType, initial: Type|null, sends: list<array{Type, TemplateTypeVariance}>, lowerBounds: list<Type>, unconstrainingSend: bool}> $observations
230247
* @param array<string, Type> $resolutions

‎src/Analyser/Traverser/GenericTypeTemplateTraverser.php‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ public function traverse(Type $type, callable $traverse): Type
6464
// markers - the real site re-keys and resolves them
6565
$synthetic = $this->site->getAttribute(TemplateArgumentFrame::SYNTHETIC_SITE_ATTRIBUTE) === true;
6666
if ($synthetic || ($this->allowUnresolved && $this->frame->isObserving())) {
67+
if (!$synthetic) {
68+
// a send in a body enclosing this one decided the site
69+
$resolved = $this->frame->resolveSentFromEnclosingBody($this->site, $type->getName());
70+
if ($resolved !== null) {
71+
return $resolved;
72+
}
73+
}
6774
if ($initialType instanceof UnresolvedTemplateArgumentType) {
6875
return $initialType;
6976
}

‎src/Reflection/ResolvedFunctionVariantWithOriginal.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,11 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance
400400
private function unresolvedOrResolvedTemplateArgument(TemplateType $template, Type $inferred, Expr $site, TemplateArgumentFrame $frame, bool $allowUnresolved, bool $onlyCovariant): Type
401401
{
402402
if ($allowUnresolved && $frame->isObserving()) {
403+
// a send in a body enclosing this one decided the site
404+
$resolved = $frame->resolveSentFromEnclosingBody($site, $template->getName());
405+
if ($resolved !== null) {
406+
return $resolved;
407+
}
403408
if ($inferred instanceof UnresolvedTemplateArgumentType) {
404409
return $inferred;
405410
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15415Types;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* @template TKey of array-key
9+
* @template TValue
10+
*/
11+
final class Coll
12+
{
13+
/**
14+
* @template TNewKey of array-key
15+
* @param callable(TValue): TNewKey $callback
16+
* @return Coll<TNewKey, TValue>
17+
*/
18+
public function keyBy(callable $callback): Coll
19+
{
20+
return new Coll();
21+
}
22+
}
23+
24+
/**
25+
* @template T
26+
* @param callable(): T $callback
27+
* @return T
28+
*/
29+
function run(callable $callback)
30+
{
31+
return $callback();
32+
}
33+
34+
final class Test
35+
{
36+
/**
37+
* @param Coll<int, int> $c
38+
* @return Coll<string, int>
39+
*/
40+
public function blockClosure(Coll $c): Coll
41+
{
42+
$r = run(function () use ($c): Coll {
43+
$keyed = $c->keyBy(fn (int $i): string => $i > 0 ? 'positive' : 'negative');
44+
assertType('Bug15415Types\Coll<string, int>', $keyed);
45+
46+
return $keyed;
47+
});
48+
assertType('Bug15415Types\Coll<string, int>', $r);
49+
50+
return $r;
51+
}
52+
53+
/**
54+
* @param Coll<int, int> $c
55+
* @return Coll<string, int>
56+
*/
57+
public function closureInVariable(Coll $c): Coll
58+
{
59+
$f = function () use ($c): Coll {
60+
return $c->keyBy(fn (int $i): string => $i > 0 ? 'positive' : 'negative');
61+
};
62+
$r = $f();
63+
assertType('Bug15415Types\Coll<string, int>', $r);
64+
65+
return $r;
66+
}
67+
}

‎tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,4 +1419,10 @@ public function testBug13325(): void
14191419
$this->analyse([__DIR__ . '/data/bug-13325.php'], []);
14201420
}
14211421

1422+
#[RequiresPhp('>= 8.0.0')]
1423+
public function testBug15415(): void
1424+
{
1425+
$this->analyse([__DIR__ . '/data/bug-15415.php'], []);
1426+
}
1427+
14221428
}

0 commit comments

Comments
 (0)