From 1826f36e4c99269fbe15d275a84aa8e775432bcf Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 11 Sep 2026 22:16:02 +0200 Subject: [PATCH] Infer closure parameter types in array arguments Closes https://github.com/phpstan/phpstan/issues/6430 Closes https://github.com/phpstan/phpstan/issues/11215 --- src/Analyser/ExprHandler/ArrayHandler.php | 45 ++++++++- .../ExprHandler/ArrowFunctionHandler.php | 6 +- src/Analyser/ExprHandler/ClosureHandler.php | 4 +- .../Helper/ClosureTypeResolver.php | 15 +-- src/Analyser/ExpressionContext.php | 30 +++++- src/Analyser/NodeScopeResolver.php | 6 +- .../nsrt/array-closure-parameters.php | 98 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-11215.php | 39 ++++++++ tests/PHPStan/Analyser/nsrt/bug-6430.php | 31 ++++++ 9 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/array-closure-parameters.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11215.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-6430.php diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index f49935fe6e..731efbd06c 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -5,6 +5,8 @@ use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; +use PhpParser\Node\Expr\ArrowFunction; +use PhpParser\Node\Expr\Closure; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Stmt; @@ -25,7 +27,11 @@ use PHPStan\Node\Variable\VariableWrite; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\ShouldNotHappenException; +use PHPStan\Type\ArrayType; use PHPStan\Type\CallableType; +use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\IntegerType; +use PHPStan\Type\MixedType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_key_exists; @@ -68,6 +74,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($literalWrite !== null && $literalWrite->isOffsetWrite()) { $literalWrite = null; } + $passedToType = $this->getExpectedArrayType($context->getPassedToType()); + $nativePassedToType = $this->getExpectedArrayType($context->getNativePassedToType()); + $hasExpectedType = $passedToType !== null || $nativePassedToType !== null; $nextIndex = 0; foreach ($expr->items as $arrayItem) { $itemNodes[] = new LiteralArrayItem($scope, $arrayItem); @@ -85,7 +94,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $valueContext = $context->enterDeepKeepingValueFlow(); - if ($literalWrite !== null) { + $keyType = null; + if ($hasExpectedType && !$arrayItem->unpack && ($arrayItem->value instanceof Array_ || $arrayItem->value instanceof Closure || $arrayItem->value instanceof ArrowFunction)) { + $keyType = $keyResult !== null ? $keyResult->getType()->toArrayKey() : ($nextIndex !== null ? new ConstantIntegerType($nextIndex) : new IntegerType()); + } + if ($literalWrite !== null || $hasExpectedType) { if ($arrayItem->unpack) { $offset = null; $nextIndex = null; @@ -102,10 +115,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nextIndex = max($nextIndex, $offset + 1); } } + } + if ($literalWrite !== null) { $itemWrite = new VariableWrite($literalWrite->getVariableName(), $arrayItem, spl_object_id($arrayItem), VariableWrite::KIND_ARRAY_LITERAL_ITEM, true, $offset, $literalWrite->getId()); $variableFlows[] = VariableFlow::write($itemWrite); $valueContext = $context->enterDeep()->enterValueFlow($itemWrite, false); } + if ($keyType !== null) { + $valueContext = $valueContext->enterPassedToType( + $this->getExpectedValueType($passedToType, $keyType), + $this->getExpectedValueType($nativePassedToType, $keyType), + ); + } $valueResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->value, $scope, $storage, $nodeCallback, $valueContext); $itemResults[spl_object_id($arrayItem->value)] = $valueResult; $variableFlows[] = $valueResult->getVariableFlow(); @@ -174,4 +195,26 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); } + private function getExpectedArrayType(?Type $type): ?Type + { + if ($type === null || $type->isIterable()->no()) { + return null; + } + + if ($type->isArray()->yes()) { + return $type; + } + + return TypeCombinator::intersect($type, new ArrayType(new MixedType(), new MixedType())); + } + + private function getExpectedValueType(?Type $arrayType, Type $keyType): ?Type + { + if ($arrayType === null || $arrayType->hasOffsetValueType($keyType)->no()) { + return null; + } + + return $arrayType->getOffsetValueType($keyType); + } + } diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index bfd50c64a1..c5ecb82d68 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -41,7 +41,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null, null, $context); + $arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, $context->getPassedToType(), $context->getNativePassedToType(), $context); $result = $arrowFunctionResult->getExpressionResult(); // A plain typeCallback recursing through getClosureType() would re-walk @@ -63,6 +63,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $arrowFunctionResult->getInvalidateExpressions(), false, $storage, + $context->getPassedToType(), + $context->getNativePassedToType(), ); $nativeType = $this->closureTypeResolver->buildClosureTypeForArrowFunction( $scope, @@ -73,6 +75,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $arrowFunctionResult->getInvalidateExpressions(), true, $storage, + $context->getPassedToType(), + $context->getNativePassedToType(), ); return $this->expressionResultFactory->create( diff --git a/src/Analyser/ExprHandler/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index 443028f030..116d6bc0d8 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -41,7 +41,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, null); + $processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, $context->getPassedToType(), $context->getNativePassedToType()); // A plain typeCallback recursing through getClosureType() would re-walk // the body each getType() ask before the cache populates and hang; @@ -67,6 +67,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $processClosureResult->getInvalidateExpressions(), false, $storage, + $context->getPassedToType(), + $context->getNativePassedToType(), ); $nativeType = $type; diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index a82b91ce17..d62b0d070b 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -308,9 +308,11 @@ public function buildClosureTypeForClosure( array $invalidateExpressions, bool $native = false, ?ExpressionResultStorage $storage = null, + ?Type $passedToType = null, + ?Type $nativePassedToType = null, ): ClosureType { - [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr, $storage); + [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr, $storage, $passedToType, $nativePassedToType); return $this->buildClosureTypeFromClosureWalk( $scope, @@ -356,9 +358,11 @@ public function buildClosureTypeForArrowFunction( array $invalidateExpressions, bool $native = false, ?ExpressionResultStorage $storage = null, + ?Type $passedToType = null, + ?Type $nativePassedToType = null, ): ClosureType { - [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr, $storage); + [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr, $storage, $passedToType, $nativePassedToType); $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr, $native, $storage); @@ -784,14 +788,13 @@ private function buildParametersAndAcceptors( MutatingScope $scope, Node\Expr\Closure|ArrowFunction $expr, ?ExpressionResultStorage $storage = null, + ?Type $passedToType = null, + ?Type $nativePassedToType = null, ): array { [$parameters, $isVariadic] = $this->buildDeclaredParameters($scope, $expr); - - $passedToType = null; - $nativePassedToType = null; $inFunctionCallsStackCount = count($scope->inFunctionCallsStack); - if ($inFunctionCallsStackCount > 0) { + if ($passedToType === null && $inFunctionCallsStackCount > 0) { [, $inParameter] = $scope->inFunctionCallsStack[$inFunctionCallsStackCount - 1]; if ($inParameter !== null) { $passedToType = $inParameter->getType(); diff --git a/src/Analyser/ExpressionContext.php b/src/Analyser/ExpressionContext.php index da509bccfa..cd4627f182 100644 --- a/src/Analyser/ExpressionContext.php +++ b/src/Analyser/ExpressionContext.php @@ -25,6 +25,8 @@ private function __construct( private bool $arrayDimFetchRoot = false, private bool $unsetTarget = false, private ?bool $valueConsumed = null, + private ?Type $passedToType = null, + private ?Type $nativePassedToType = null, ) { } @@ -46,7 +48,7 @@ public static function createDeep(bool $resolveTemplateArguments = true): self */ public function enterDeep(): self { - if ($this->isDeep && $this->valueFlowTarget === null && !$this->arrayDimFetchRoot && !$this->unsetTarget) { + if ($this->isDeep && $this->valueFlowTarget === null && !$this->arrayDimFetchRoot && !$this->unsetTarget && $this->passedToType === null && $this->nativePassedToType === null) { return $this; } @@ -74,7 +76,7 @@ public function enterDeepKeepingValueFlow(): self */ public function withoutValueFlow(): self { - if ($this->valueFlowTarget === null && !$this->arrayDimFetchRoot && !$this->unsetTarget) { + if ($this->valueFlowTarget === null && !$this->arrayDimFetchRoot && !$this->unsetTarget && $this->passedToType === null && $this->nativePassedToType === null) { return $this; } @@ -92,6 +94,30 @@ public function isValueConsumed(): bool return $this->valueFlowTarget !== null || ($this->valueConsumed ?? $this->isDeep); } + /** Applies only to this expression; child expressions must receive their own expected types. */ + public function enterPassedToType(?Type $type, ?Type $nativeType): self + { + if ($this->passedToType === $type && $this->nativePassedToType === $nativeType) { + return $this; + } + + $context = clone $this; + $context->passedToType = $type; + $context->nativePassedToType = $nativeType; + + return $context; + } + + public function getPassedToType(): ?Type + { + return $this->passedToType; + } + + public function getNativePassedToType(): ?Type + { + return $this->nativePassedToType; + } + public function isDeep(): bool { return $this->isDeep; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 10f6012453..2220437ee3 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3143,7 +3143,11 @@ public function processArgs( if ($enterExpressionAssignForByRef) { $scopeToPass = $scopeToPass->enterExpressionAssign($arg->value); } - $exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context->enterDeep()); + $argContext = $context->enterDeep(); + if (!$arg->unpack && $arg->value instanceof Expr\Array_) { + $argContext = $argContext->enterPassedToType($parameterType, $parameterNativeType); + } + $exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $argContext); $argResults[spl_object_id($arg->value)] = $exprResult; $exprType = $exprResult->getType(); $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); diff --git a/tests/PHPStan/Analyser/nsrt/array-closure-parameters.php b/tests/PHPStan/Analyser/nsrt/array-closure-parameters.php new file mode 100644 index 0000000000..f88d3084a7 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-closure-parameters.php @@ -0,0 +1,98 @@ += 8.0 + +declare(strict_types = 1); + +namespace ArrayClosureParameters; + +use Closure; +use function PHPStan\Testing\assertNativeType; +use function PHPStan\Testing\assertType; + +/** @param list $callbacks */ +function callbacks(array $callbacks): void {} + +callbacks([ + function ($value, $key) { + assertType('string', $value); + assertType('int', $key); + assertNativeType('mixed', $value); + assertNativeType('mixed', $key); + return $value; + }, + fn ($value, $key) => [assertType('string', $value), assertType('int', $key), assertNativeType('mixed', $value)], + function (int $value, $key) { + assertType('int', $value); + assertType('int', $key); + }, +]); + +/** @param array{first: callable(string): mixed, second?: Closure(int): mixed} $callbacks */ +function shape(array $callbacks): void {} + +shape(callbacks: [ + 'second' => fn ($value) => assertType('int', $value), + 'first' => function ($value) { + assertType('string', $value); + $unrelated = [function ($other) { + assertType('mixed', $other); + }]; + }, +]); + +/** @param array{callable(string): mixed, callable(int): mixed} $callbacks */ +function tuple(array $callbacks): void {} + +tuple([ + fn ($value) => assertType('string', $value), + fn ($value) => assertType('int', $value), +]); + +/** @param array{5: string, 6: callable(int): mixed} $callbacks */ +function numericShape(array $callbacks): void {} + +numericShape([5 => 'value', fn ($value) => assertType('int', $value)]); + +/** @param array> $callbacks */ +function nested(array $callbacks): void {} + +nested(['first' => [function ($value) { + assertType('string', $value); +}]]); + +/** @param list|null $callbacks */ +function nullable(?array $callbacks): void {} + +nullable([fn ($value) => assertType('string', $value)]); + +/** @param iterable $callbacks */ +function iterableCallbacks(iterable $callbacks): void {} + +iterableCallbacks([fn ($value) => assertType('string', $value)]); + +/** + * @template T + * @param T $value + * @param list $callbacks + */ +function generic($value, array $callbacks): void {} + +generic(new \stdClass(), [fn ($value) => assertType('stdClass', $value)]); + +class Receiver +{ + /** @param list $callbacks */ + public function __construct(array $callbacks) {} + + /** @param list $callbacks */ + public static function run(array $callbacks): void {} + + /** @param list ...$callbacks */ + public function variadic(array ...$callbacks): void {} +} + +$receiver = new Receiver([fn ($value) => assertType('string', $value)]); +Receiver::run([fn ($value) => assertType('int', $value)]); +$receiver->variadic([fn ($value) => assertType('string', $value)], [fn ($value) => assertType('string', $value)]); + +$unrelated = [fn ($value) => assertType('mixed', $value)]; +callbacks($unrelated); diff --git a/tests/PHPStan/Analyser/nsrt/bug-11215.php b/tests/PHPStan/Analyser/nsrt/bug-11215.php new file mode 100644 index 0000000000..5cc18f16c1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11215.php @@ -0,0 +1,39 @@ +): mixed $relation */ + public function load($relation): void + { + // + } + + /** @param array): mixed)|string> $relations */ + public function loadMany($relations): void + { + // + } + +} + +/** @var Collection $users */ +$users->load(function ($query) { + assertType('Bug11215\Builder', $query); +}); + +$users->loadMany(['foo' => function ($query) { + assertType('Bug11215\Builder', $query); +}]); diff --git a/tests/PHPStan/Analyser/nsrt/bug-6430.php b/tests/PHPStan/Analyser/nsrt/bug-6430.php new file mode 100644 index 0000000000..be0094992c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-6430.php @@ -0,0 +1,31 @@ + $callback + */ + public function sayHello($callback): void + { + + } + +} + +/** @var HelloWorld */ +$a = new HelloWorld; + +$a->sayHello([function ($u, $i) { + assertType('string', $u); + assertType('int', $i); + return true; +}]);