Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/Analyser/ExprHandler/ArrowFunctionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
6 changes: 5 additions & 1 deletion src/Analyser/ExprHandler/ClosureHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand All @@ -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) {
Expand Down
135 changes: 134 additions & 1 deletion src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<string, true> filePath(string) => bool(true) */
private array $analysedFiles = [];

Expand Down Expand Up @@ -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<T, Closure(T)> 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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-11215.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php declare(strict_types = 1);

namespace Bug11215;

use function PHPStan\Testing\assertType;

class User {}

/** @template TModel */
class Builder
{
}

/** @template TModel */
class Collection
{
/** @param callable(Builder<TModel>): mixed $relation */
public function load($relation): void
{
//
}

/** @param array<string, (callable(Builder<TModel>): mixed)|string> $relations */
public function loadMany($relations): void
{
//
}
}

/** @var Collection<User> $users */
$users->load(function ($query) {
assertType('Bug11215\Builder<Bug11215\User>', $query);
});

$users->loadMany(['foo' => function ($query) {
assertType('Bug11215\Builder<Bug11215\User>', $query);
}]);
29 changes: 29 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-6430.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php declare(strict_types = 1);

namespace Bug6430;

use function PHPStan\Testing\assertType;

/**
* @template TKey of array-key
* @template TValue
*/
class HelloWorld
{
/**
* @param array<int, (\Closure(TValue, TKey): mixed)> $callback
*/
public function sayHello($callback): void
{

}
}

/** @var HelloWorld<int, string> */
$a = new HelloWorld;

$a->sayHello([function ($u, $i) {
assertType('string', $u);
assertType('int', $i);
return true;
}]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php // lint >= 8.1

declare(strict_types = 1);

namespace ClosurePassedToTypeNestedUnpack;

use function PHPStan\Testing\assertType;

/**
* @template T
* @param array<T, \Closure(T): void> $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);
},
]);
};
Loading
Loading