Skip to content

Commit 606b86e

Browse files
ondrejmirtesclaude
andcommitted
Walk closures in array-literal arguments after their siblings
An array literal argument holding closures decides the templates of its own parameter through its keys and other values - and those templates type the closures in it. ArgumentsHandler used to resolve the parameter from a pre-walk skeleton of the literal priced by InitializerExprTypeResolver, which crashed on first-class callables it cannot price ($obj->method(...), $f(...)) and read stale scope state after items with side effects. ArgumentsHandler now hands ArrayHandler a passed-to type resolver through ExpressionContext. ArrayHandler walks the keys and non-closure values first with their real handlers, resolves the parameter from their types plus the closures' declared signatures, then walks the deferred closures - each on the scope at its own position, carrying their template argument constraints over to the literal's scope. A closure with a by-reference use is walked in place, priced from the items before it. A plain variable key is now read after its value, when PHP adds the item: [$j => $j++] is array{1: 0}. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEjx9cAdSfGZDQdTWKaX64
1 parent 393f970 commit 606b86e

18 files changed

Lines changed: 2650 additions & 920 deletions

‎src/Analyser/ArgumentsHandler.php‎

Lines changed: 83 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@
3232
use PHPStan\Reflection\ExtendedMethodReflection;
3333
use PHPStan\Reflection\ExtendedParameterReflection;
3434
use PHPStan\Reflection\FunctionReflection;
35-
use PHPStan\Reflection\InitializerExprContext;
36-
use PHPStan\Reflection\InitializerExprTypeResolver;
3735
use PHPStan\Reflection\MethodReflection;
3836
use PHPStan\Reflection\ParameterReflection;
3937
use PHPStan\Reflection\ParametersAcceptor;
@@ -125,7 +123,6 @@ public function __construct(
125123
private AssignHandler $assignHandler,
126124
private ClosureTypeResolver $closureTypeResolver,
127125
private ClosureParameterResolver $closureParameterResolver,
128-
private InitializerExprTypeResolver $initializerExprTypeResolver,
129126
)
130127
{
131128
}
@@ -242,94 +239,78 @@ public function processArgs(
242239
? $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope)
243240
: $this->closureTypeResolver->getClosureType($scope, $arg->value, true, $storage);
244241
$this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArgForGather, $i, $gatheredArgTypeByIndex[$i]);
245-
} elseif (
246-
$argMetadataIsTypeDriven
247-
&& !$arg->unpack
248-
&& $arg->value instanceof Expr\Array_
249-
&& $this->argConsumesResolvedParameterType($arg->value)
250-
) {
251-
// An array literal holding closures decides the templates of its own
252-
// parameter through its keys and its non-closure values - and those
253-
// very templates type the closures nested in it. Pin a SKELETON of the
254-
// array (declared closure signatures, scope-known leaves, mixed for
255-
// anything that needs a walk) so the per-argument resolution below sees
256-
// them; the walk that follows overwrites it with the real type.
257-
$gatheredArgTypeByIndex[$i] = $this->gatherArrayArgTypeSkeleton($nodeScopeResolver, $arg->value, $scope);
258242
}
259243

260244
$argMetadataAcceptor = $metadataAcceptor;
245+
$passedToTypeResolver = null;
261246
if ($metadataAcceptor !== null && $argMetadataIsTypeDriven) {
262-
if ($this->argConsumesResolvedParameterType($arg->value)) {
263-
// Resolve the acceptor for this argument from the args gathered SO FAR, padded to the
264-
// full argument count with mixed. Closures sort last and by-ref out-params follow the
265-
// args that pin them, so determining siblings are already processed; the mixed pad keeps
266-
// the argument COUNT correct so the by-ref/variadic variant stays stable (e.g. sscanf),
267-
// while processed siblings resolve a generic callable(T) parameter. No forward read.
268-
$paddedTypes = [];
269-
$paddedUnpack = false;
270-
$paddedHasName = false;
271-
foreach ($args as $j => $paddedArg) {
272-
$paddedOriginalArg = $paddedArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $paddedArg;
273-
$this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, $gatheredArgTypeByIndex[$j] ?? new MixedType());
274-
}
275-
$argMetadataAcceptor = $this->selectArgsMetadataAcceptor($nodeScopeResolver, $args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope);
276-
} else {
247+
if (!$this->argConsumesResolvedParameterType($arg->value)) {
277248
// Only a closure/arrow function consumes the generic-RESOLVED
278249
// parameter type: its body is inferred from the resolved
279250
// callable(T) - directly, or through the in-function-call stack
280251
// when nested anywhere inside the argument. Every other argument
281252
// reads variant-stable facts off its parameter (by-ref flag,
282253
// callable bookkeeping), so one all-mixed count-stable selection
283254
// serves them all instead of a full template inference per argument.
284-
if ($countStableMetadataAcceptor === null) {
255+
$countStableMetadataAcceptor ??= $this->selectCountStableMetadataAcceptor($nodeScopeResolver, $args, $parametersAcceptors, $namedArgumentsVariants, $scope);
256+
$argMetadataAcceptor = $countStableMetadataAcceptor;
257+
} elseif (!$arg->unpack && $arg->value instanceof Expr\Array_) {
258+
// An array literal holding closures decides the templates of its
259+
// own parameter through its keys and its other values - and those
260+
// very templates type the closures in it. ArrayHandler walks the
261+
// other items first and resolves the parameter from their types;
262+
// the argument's own metadata is variant-stable - the declared
263+
// parameter of a single variant, as gatherClosureArgType() pushes.
264+
$passedToTypeResolver = function (Type $arrayType) use ($nodeScopeResolver, $args, $arg, $i, $gatheredArgTypeByIndex, $parametersAcceptors, $namedArgumentsVariants, $scope): array {
285265
$paddedTypes = [];
286266
$paddedUnpack = false;
287267
$paddedHasName = false;
288268
foreach ($args as $j => $paddedArg) {
289269
$paddedOriginalArg = $paddedArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $paddedArg;
290-
$this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, new MixedType());
270+
$this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, $j === $i ? $arrayType : ($gatheredArgTypeByIndex[$j] ?? new MixedType()));
271+
}
272+
$parameter = $this->matchArgParameter(
273+
$this->selectArgsMetadataAcceptor($nodeScopeResolver, $args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope),
274+
$arg,
275+
$i,
276+
);
277+
if ($parameter === null) {
278+
return [null, null];
291279
}
292-
$countStableMetadataAcceptor = $this->selectArgsMetadataAcceptor($nodeScopeResolver, $args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope);
280+
281+
return [$parameter->getType(), $parameter instanceof ExtendedParameterReflection ? $parameter->getNativeType() : null];
282+
};
283+
if (count($parametersAcceptors) > 1 || $namedArgumentsVariants !== null) {
284+
$countStableMetadataAcceptor ??= $this->selectCountStableMetadataAcceptor($nodeScopeResolver, $args, $parametersAcceptors, $namedArgumentsVariants, $scope);
285+
$argMetadataAcceptor = $countStableMetadataAcceptor;
293286
}
294-
$argMetadataAcceptor = $countStableMetadataAcceptor;
287+
} else {
288+
// Resolve the acceptor for this argument from the args gathered SO FAR, padded to the
289+
// full argument count with mixed. Closures sort last and by-ref out-params follow the
290+
// args that pin them, so determining siblings are already processed; the mixed pad keeps
291+
// the argument COUNT correct so the by-ref/variadic variant stays stable (e.g. sscanf),
292+
// while processed siblings resolve a generic callable(T) parameter. No forward read.
293+
$paddedTypes = [];
294+
$paddedUnpack = false;
295+
$paddedHasName = false;
296+
foreach ($args as $j => $paddedArg) {
297+
$paddedOriginalArg = $paddedArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $paddedArg;
298+
$this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, $gatheredArgTypeByIndex[$j] ?? new MixedType());
299+
}
300+
$argMetadataAcceptor = $this->selectArgsMetadataAcceptor($nodeScopeResolver, $args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope);
295301
}
296302
}
297-
$parameters = $argMetadataAcceptor !== null ? $argMetadataAcceptor->getParameters() : null;
298303

299304
$assignByReference = false;
300-
$parameter = null;
305+
$parameter = $argMetadataAcceptor !== null ? $this->matchArgParameter($argMetadataAcceptor, $arg, $i) : null;
301306
$parameterType = null;
302307
$parameterNativeType = null;
303-
if ($parameters !== null) {
304-
$matchedParameter = null;
305-
if ($arg->name !== null) {
306-
foreach ($parameters as $p) {
307-
if ($p->getName() === $arg->name->toString()) {
308-
$matchedParameter = $p;
309-
break;
310-
}
311-
}
312-
} elseif (isset($parameters[$i])) {
313-
$matchedParameter = $parameters[$i];
314-
}
315-
316-
if ($matchedParameter !== null) {
317-
$assignByReference = $matchedParameter->passedByReference()->createsNewVariable();
318-
$parameterType = $matchedParameter->getType();
308+
if ($parameter !== null) {
309+
$assignByReference = $parameter->passedByReference()->createsNewVariable();
310+
$parameterType = $parameter->getType();
319311

320-
if ($matchedParameter instanceof ExtendedParameterReflection) {
321-
$parameterNativeType = $matchedParameter->getNativeType();
322-
}
323-
$parameter = $matchedParameter;
324-
} elseif (count($parameters) > 0 && $argMetadataAcceptor->isVariadic()) {
325-
$lastParameter = array_last($parameters);
326-
$assignByReference = $lastParameter->passedByReference()->createsNewVariable();
327-
$parameterType = $lastParameter->getType();
328-
329-
if ($lastParameter instanceof ExtendedParameterReflection) {
330-
$parameterNativeType = $lastParameter->getNativeType();
331-
}
332-
$parameter = $lastParameter;
312+
if ($parameter instanceof ExtendedParameterReflection) {
313+
$parameterNativeType = $parameter->getNativeType();
333314
}
334315
}
335316

@@ -645,6 +626,9 @@ public function processArgs(
645626
$argContext = $context->enterDeep();
646627
if (!$arg->unpack && $arg->value instanceof Expr\Array_) {
647628
$argContext = $argContext->enterPassedToType($parameterType, $parameterNativeType);
629+
if ($passedToTypeResolver !== null) {
630+
$argContext = $argContext->enterPassedToTypeResolver($passedToTypeResolver);
631+
}
648632
}
649633
$exprResult = $nodeScopeResolver->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $argContext);
650634
$argResults[spl_object_id($arg->value)] = $exprResult;
@@ -991,37 +975,45 @@ private function gatherClosureArgType(array $parametersAcceptors, int $i, Expr $
991975
}
992976

993977
/**
994-
* A structural stand-in for an array literal argument that holds closures,
995-
* built without walking anything: a nested array literal recurses, a closure /
996-
* arrow function contributes its DECLARED signature
997-
* (ClosureTypeResolver::getDeclaredClosureType()), and every other key/value
998-
* is priced by the scope state it is already tracked as, falling back to the
999-
* constant-expression resolver (literals, ::class, constants, concatenation)
1000-
* and ultimately to mixed. Widening a slot to mixed is safe: a template is
1001-
* never resolved below its bound, so the skeleton can only ever sharpen the
1002-
* resolution.
1003-
*
1004-
* Unlike gatherClosureArgType() this type never reaches $gatheredTypes: it
1005-
* exists solely to resolve the parameter type the nested closures are typed
1006-
* from. The argument's real type replaces it once the walk is done.
978+
* @param Node\Arg[] $args
979+
* @param ParametersAcceptor[] $parametersAcceptors
980+
* @param ParametersAcceptor[]|null $namedArgumentsVariants
1007981
*/
1008-
private function gatherArrayArgTypeSkeleton(NodeScopeResolver $nodeScopeResolver, Expr\Array_ $expr, MutatingScope $scope): Type
982+
private function selectCountStableMetadataAcceptor(NodeScopeResolver $nodeScopeResolver, array $args, array $parametersAcceptors, ?array $namedArgumentsVariants, MutatingScope $scope): ParametersAcceptor
1009983
{
1010-
$initializerContext = InitializerExprContext::fromScope($scope);
1011-
$getType = function (Expr $inner) use (&$getType, $nodeScopeResolver, $scope, $initializerContext): Type {
1012-
if ($inner instanceof Expr\Closure || $inner instanceof Expr\ArrowFunction) {
1013-
return $this->closureTypeResolver->getDeclaredClosureType($scope, $inner);
1014-
}
984+
$paddedTypes = [];
985+
$paddedUnpack = false;
986+
$paddedHasName = false;
987+
foreach ($args as $j => $paddedArg) {
988+
$paddedOriginalArg = $paddedArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $paddedArg;
989+
$this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, new MixedType());
990+
}
1015991

1016-
if ($inner instanceof Expr\Array_) {
1017-
return $this->initializerExprTypeResolver->getArrayType($inner, $getType);
992+
return $this->selectArgsMetadataAcceptor($nodeScopeResolver, $args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope);
993+
}
994+
995+
/**
996+
* The parameter the argument is passed to: by name, by position, or the
997+
* variadic one.
998+
*/
999+
private function matchArgParameter(ParametersAcceptor $acceptor, Node\Arg $arg, int $i): ?ParameterReflection
1000+
{
1001+
$parameters = $acceptor->getParameters();
1002+
if ($arg->name !== null) {
1003+
foreach ($parameters as $parameter) {
1004+
if ($parameter->getName() === $arg->name->toString()) {
1005+
return $parameter;
1006+
}
10181007
}
1008+
} elseif (isset($parameters[$i])) {
1009+
return $parameters[$i];
1010+
}
10191011

1020-
return $nodeScopeResolver->findScopeStateType($inner, $scope)
1021-
?? $this->initializerExprTypeResolver->getType($inner, $initializerContext);
1022-
};
1012+
if (count($parameters) > 0 && $acceptor->isVariadic()) {
1013+
return array_last($parameters);
1014+
}
10231015

1024-
return $this->initializerExprTypeResolver->getArrayType($expr, $getType);
1016+
return null;
10251017
}
10261018

10271019
/**

0 commit comments

Comments
 (0)