diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index b811653920a..23ee83f528d 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -41,7 +41,11 @@ 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); + // an arrow function nested inside a call argument (array literal, + // ternary) carries the parameter type it is passed to - see + // NodeScopeResolver::annotateNestedClosuresWithPassedToType() + [$passedToType, $nativePassedToType] = $expr->getAttribute(NodeScopeResolver::CLOSURE_PASSED_TO_TYPE_ATTRIBUTE) ?? [null, null]; + $arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, $passedToType, $nativePassedToType); $this->closureTypeResolver->seedCacheFromArrowFunctionWalk($scope, $expr, $arrowFunctionResult); $result = $arrowFunctionResult->getExpressionResult(); diff --git a/src/Analyser/ExprHandler/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index 07f600f69d4..2ec9add8805 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -41,7 +41,11 @@ 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); + // a closure nested inside a call argument (array literal, ternary) + // carries the parameter type it is passed to - see + // NodeScopeResolver::annotateNestedClosuresWithPassedToType() + [$passedToType, $nativePassedToType] = $expr->getAttribute(NodeScopeResolver::CLOSURE_PASSED_TO_TYPE_ATTRIBUTE) ?? [null, null]; + $processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, $passedToType, $nativePassedToType); $this->closureTypeResolver->seedCacheFromClosureWalk($scope, $expr, $processClosureResult); return $this->expressionResultFactory->create( diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index cb8411fed35..e4009a4c1df 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -803,6 +803,7 @@ private function buildParametersAndAcceptors( $nativeCallableParameters = null; $arrayMapArgs = $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME); $immediatelyInvokedArgs = $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME); + $passedTo = $expr->getAttribute(NodeScopeResolver::CLOSURE_PASSED_TO_TYPE_ATTRIBUTE); if ($arrayMapArgs !== null) { $callableParameters = []; $nativeCallableParameters = []; @@ -815,6 +816,13 @@ private function buildParametersAndAcceptors( $callableParameters[] = new DummyParameter('item', $scope->getType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } + } elseif ($passedTo !== null) { + // nested inside a call argument: the projected parameter type, not + // the enclosing parameter on the in-function-call stack (that one + // describes the whole argument, e.g. the array of closures) + [$passedToType, $nativePassedToType] = $passedTo; + $callableParameters = $this->nodeScopeResolver->createCallableParameters($scope, $expr, null, $passedToType); + $nativeCallableParameters = $this->nodeScopeResolver->createNativeCallableParameters($scope, $expr, null, $nativePassedToType ?? $passedToType); } else { $inFunctionCallsStackCount = count($scope->inFunctionCallsStack); if ($inFunctionCallsStackCount > 0) { diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 90e7657c837..e3d71d69dc4 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -65,6 +65,7 @@ use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\Native\NativeMethodReflection; use PHPStan\Reflection\Native\NativeParameterReflection; @@ -77,6 +78,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\ClosureType; +use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\FunctionParameterClosureThisExtension; use PHPStan\Type\FunctionParameterClosureTypeExtension; @@ -121,6 +123,15 @@ class NodeScopeResolver public const LOOP_SCOPE_ITERATIONS = 3; public const GENERALIZE_AFTER_ITERATION = 1; + /** + * Set on a closure/arrow function nested inside a call argument (through + * array literals and ternaries) to the parameter type it ends up passed + * to, as array{Type, Type|null} (phpdoc type, native type). + * A closure that IS the argument gets that type handed to + * processClosureNode() directly and never carries the attribute. + */ + public const CLOSURE_PASSED_TO_TYPE_ATTRIBUTE = 'phpstanClosurePassedToType'; + /** @var array filePath(string) => bool(true) */ private array $analysedFiles = []; @@ -1934,12 +1945,19 @@ public function processArgs( // args that pin them, so determining siblings are already processed; the mixed pad keeps // the argument COUNT correct so the by-ref/variadic variant stays stable (e.g. sscanf), // while processed siblings resolve a generic callable(T) parameter. No forward read. + // An argument that only CONTAINS closures (array literal, ternary) pins template + // types itself - array reads T off the keys - so it is padded with + // its structural type: the literal with each nested closure's declared signature. $paddedTypes = []; $paddedUnpack = false; $paddedHasName = false; foreach ($args as $j => $paddedArg) { $paddedOriginalArg = $paddedArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $paddedArg; - $this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, $gatheredArgTypeByIndex[$j] ?? new MixedType()); + $paddedType = $gatheredArgTypeByIndex[$j] ?? null; + if ($paddedType === null && $j === $i) { + $paddedType = $this->getStructuralArgType($scope, $arg->value); + } + $this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, $paddedType ?? new MixedType()); } $argMetadataAcceptor = $this->selectArgsMetadataAcceptor($args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope); } else { @@ -2178,6 +2196,9 @@ public function processArgs( // getType() answers from the stored result $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); } else { + if ($parameterType !== null) { + $this->annotateNestedClosuresWithPassedToType($scope, $arg->value, $parameterType, $parameterNativeType); + } $exprType = $scope->getType($arg->value); $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; if ($enterExpressionAssignForByRef) { @@ -2465,6 +2486,118 @@ private function gatherClosureArgType(array $parametersAcceptors, int $i, Expr $ return $scope->getType($closureExpr); } + /** + * The type of an argument that contains closures, without walking any + * closure body: array literals and ternaries are built structurally, + * a nested closure/arrow function contributes its declared signature + * only, and any other closure-containing expression (a call taking a + * closure, ...) is mixed. Sub-expressions without a closure inside read + * their scope type as usual. + * + * Null when the expression is not an array literal or ternary - there + * is nothing structural to read and the caller pads with mixed. + */ + private function getStructuralArgType(MutatingScope $scope, Expr $expr): ?Type + { + if ($expr instanceof Expr\Ternary) { + $ifType = $this->getStructuralItemType($scope, $expr->if ?? $expr->cond); + $elseType = $this->getStructuralItemType($scope, $expr->else); + + return TypeCombinator::union($ifType, $elseType); + } + + if (!$expr instanceof Expr\Array_) { + return null; + } + + return $this->container->getByType(InitializerExprTypeResolver::class)->getArrayType($expr, fn (Expr $itemExpr): Type => $this->getStructuralItemType($scope, $itemExpr)); + } + + private function getStructuralItemType(MutatingScope $scope, Expr $expr): Type + { + if ($expr instanceof Expr\Closure || $expr instanceof Expr\ArrowFunction) { + return $this->container->getByType(ClosureTypeResolver::class)->getClosureType($scope, $expr, true); + } + + if (!$this->argConsumesResolvedParameterType($expr)) { + return $scope->getType($expr); + } + + return $this->getStructuralArgType($scope, $expr) ?? new MixedType(); + } + + /** + * Projects the parameter type an argument is passed to onto the + * closures/arrow functions nested inside it, so their parameters are + * inferred the same way as for a closure that IS the argument. + * + * Walks array literals (projecting through the key: a literal key or the + * auto-index reads the offset value type, so array shapes resolve per + * item) and both branches of a ternary. Any other expression stops the + * projection - its value is not the argument value itself. + */ + private function annotateNestedClosuresWithPassedToType(Scope $scope, Expr $expr, Type $type, ?Type $nativeType): void + { + if ($expr instanceof Expr\Closure || $expr instanceof Expr\ArrowFunction) { + $expr->setAttribute(self::CLOSURE_PASSED_TO_TYPE_ATTRIBUTE, [$type, $nativeType]); + return; + } + + if (!$this->argConsumesResolvedParameterType($expr)) { + return; + } + + if ($expr instanceof Expr\Ternary) { + if ($expr->if !== null) { + $this->annotateNestedClosuresWithPassedToType($scope, $expr->if, $type, $nativeType); + } + $this->annotateNestedClosuresWithPassedToType($scope, $expr->else, $type, $nativeType); + return; + } + + if (!$expr instanceof Expr\Array_) { + return; + } + + // only the array members of a union describe the literal's items + if ($type instanceof UnionType) { + $type = $type->filterTypes(static fn (Type $innerType) => $innerType->isArray()->yes()); + if ($type->isArray()->no()) { + return; + } + } + if ($nativeType instanceof UnionType) { + $nativeType = $nativeType->filterTypes(static fn (Type $innerType) => $innerType->isArray()->yes()); + } + + $nextAutoIndex = 0; + foreach ($expr->items as $item) { + if ($item->unpack) { + // the unpacked count is unknown, later auto-indexes are too + $nextAutoIndex = null; + continue; + } + + if ($item->key === null) { + $keyType = $nextAutoIndex !== null ? new ConstantIntegerType($nextAutoIndex++) : null; + } else { + $keyType = $scope->getType($item->key); + $keyValues = $keyType->getConstantScalarValues(); + if ($nextAutoIndex !== null && count($keyValues) === 1 && is_int($keyValues[0])) { + $nextAutoIndex = max($nextAutoIndex, $keyValues[0] + 1); + } + } + + $itemType = $keyType !== null ? $type->getOffsetValueType($keyType) : $type->getIterableValueType(); + $itemNativeType = null; + if ($nativeType !== null) { + $itemNativeType = $keyType !== null ? $nativeType->getOffsetValueType($keyType) : $nativeType->getIterableValueType(); + } + + $this->annotateNestedClosuresWithPassedToType($scope, $item->value, $itemType, $itemNativeType); + } + } + /** * Whether processing this argument consumes the generic-RESOLVED parameter * type: a closure/arrow function does - its parameters and body scope are diff --git a/tests/PHPStan/Analyser/nsrt/bug-11215.php b/tests/PHPStan/Analyser/nsrt/bug-11215.php new file mode 100644 index 00000000000..10a29b92814 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11215.php @@ -0,0 +1,37 @@ +): 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 00000000000..b4d4c60009f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-6430.php @@ -0,0 +1,29 @@ + $callback + */ + public function sayHello($callback): void + { + + } +} + +/** @var HelloWorld */ +$a = new HelloWorld; + +$a->sayHello([function ($u, $i) { + assertType('string', $u); + assertType('int', $i); + return true; +}]); diff --git a/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-nested-unpack.php b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-nested-unpack.php new file mode 100644 index 00000000000..6a03f83c19f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-nested-unpack.php @@ -0,0 +1,23 @@ += 8.1 + +declare(strict_types = 1); + +namespace ClosurePassedToTypeNestedUnpack; + +use function PHPStan\Testing\assertType; + +/** + * @template T + * @param array $callbacks + */ +function acceptKeyedGenericWithUnpack(array $callbacks): void {} + +/** @param array<'z', \Closure('z'): void> $more */ +function withUnpack(array $more): void { + acceptKeyedGenericWithUnpack([ + ...$more, + 'foo' => function ($value): void { + assertType("'foo'|'z'", $value); + }, + ]); +}; diff --git a/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-nested.php b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-nested.php new file mode 100644 index 00000000000..b10df61a2f7 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-nested.php @@ -0,0 +1,151 @@ + $callbacks */ +function acceptCallbacks(array $callbacks): void {} + +acceptCallbacks([ + 'foo' => function ($value): void { + assertType('int', $value); + assertNativeType('mixed', $value); + }, + 'bar' => fn ($value) => assertType('int', $value), +]); + +/** @param array{first: \Closure(int): void, second: \Closure(string): void, \Closure(float): void} $callbacks */ +function acceptShape(array $callbacks): void {} + +acceptShape([ + 'first' => function ($value): void { + assertType('int', $value); + }, + 'second' => function ($value): void { + assertType('string', $value); + }, + function ($value): void { + assertType('float', $value); + }, +]); + +/** @param list<\Closure(int): void> $callbacks */ +function acceptList(array $callbacks): void {} + +acceptList([ + function ($value): void { + assertType('int', $value); + }, + function ($value): void { + assertType('int', $value); + }, +]); + +/** @param array> $callbacks */ +function acceptNested(array $callbacks): void {} + +acceptNested([ + 'a' => [ + 'b' => function ($value): void { + assertType('bool', $value); + }, + ], +]); + +/** @param \Closure(int): void|array $callbacks */ +function acceptUnion($callbacks): void {} + +acceptUnion(function ($value): void { + assertType('int', $value); +}); + +acceptUnion([ + 'foo' => function ($value): void { + assertType('string', $value); + }, +]); + +/** + * @template T + * @param list $items + * @param array $callbacks + */ +function acceptGeneric(array $items, array $callbacks): void {} + +acceptGeneric([1, 2, 3], [ + 'foo' => function ($value): void { + assertType('1|2|3', $value); + }, +]); + +/** @param array $callbacks */ +function acceptTernary(array $callbacks): void {} + +acceptTernary(rand(0, 1) === 0 ? [ + 'foo' => function ($value): void { + assertType('int', $value); + }, +] : [ + 'bar' => function ($value): void { + assertType('int', $value); + }, +]); + +/** @param array $callbacks */ +function acceptReturnType(array $callbacks): void {} + +acceptReturnType([ + 'foo' => function ($value) { + return $value + 1; + }, +]); + +function (array $callbacks): void { + acceptCallbacks([ + 'foo' => function (string $value): void { + assertType('string', $value); + }, + ]); +}; + +/** + * @template T + * @param array $callbacks + */ +function acceptKeyedGeneric(array $callbacks): void {} + +acceptKeyedGeneric([ + 'foo' => function ($value): void { + assertType("'bar'|'foo'", $value); + }, + 'bar' => fn ($value) => assertType("'bar'|'foo'", $value), +]); + +/** + * @template T + * @param array $values + * @param array $callbacks + */ +function acceptGenericTernaryItem(array $values, array $callbacks): void {} + +acceptGenericTernaryItem([1, 2], [ + rand(0, 1) === 0 ? function ($value): void { + assertType('1|2', $value); + } : function ($value): void { + assertType('1|2', $value); + }, +]); + +/** + * @template T + * @param array $callbacks + */ +function acceptNestedGeneric(array $callbacks): void {} + +acceptNestedGeneric([ + 'a' => ['x', function ($value): void { + assertType("'x'", $value); + }], +]);