diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index bd465a40fa..8b14b4a877 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -10,6 +10,7 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParameterReflection; use PHPStan\Type\Type; +use function array_pop; final class FiberScope extends MutatingScope { @@ -140,14 +141,64 @@ private function preprocessScope(MutatingScope $scope): Scope */ public function pushInFunctionCall($reflection, ?ParameterReflection $parameter, bool $rememberTypes): self { - // no need to track this in rules, the type will be correct anyway - return $this; + // Track the function call stack so closure types can be properly inferred + // when the closure is passed as a callable argument + $stack = $this->inFunctionCallsStack; + $stack[] = [$reflection, $parameter]; + + /** @var self $scope */ + $scope = $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->getAnonymousFunctionReflection(), + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $stack, + $this->afterExtractCall, + parent::getParentScope(), + $this->nativeTypesPromoted, + ); + $scope->truthyValueExprs = $this->truthyValueExprs; + $scope->falseyValueExprs = $this->falseyValueExprs; + + return $scope; } public function popInFunctionCall(): self { - // no need to track this in rules, the type will be correct anyway - return $this; + $stack = $this->inFunctionCallsStack; + array_pop($stack); + + /** @var self $scope */ + $scope = $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->getAnonymousFunctionReflection(), + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $stack, + $this->afterExtractCall, + parent::getParentScope(), + $this->nativeTypesPromoted, + ); + $scope->truthyValueExprs = $this->truthyValueExprs; + $scope->falseyValueExprs = $this->falseyValueExprs; + + return $scope; } public function getParentScope(): ?MutatingScope diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index fb8d94671a..596dcc4be0 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -5,7 +5,6 @@ use Closure; use PhpParser\Node; use PHPStan\Analyser\ArgumentsNormalizer; -use PHPStan\Analyser\Fiber\FiberScope; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr; @@ -498,14 +497,14 @@ public static function selectFromArgs( } } - if ($parameter !== null && $scope instanceof MutatingScope && !$scope instanceof FiberScope) { + if ($parameter !== null && $scope instanceof MutatingScope) { $rememberTypes = !$originalArg->value instanceof Node\Expr\Closure && !$originalArg->value instanceof Node\Expr\ArrowFunction; $scope = $scope->pushInFunctionCall(null, $parameter, $rememberTypes); } $type = $scope->getType($originalArg->value); - if ($parameter !== null && $scope instanceof MutatingScope && !$scope instanceof FiberScope) { + if ($parameter !== null && $scope instanceof MutatingScope) { $scope = $scope->popInFunctionCall(); } diff --git a/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-fiberscope-php81.php b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-fiberscope-php81.php new file mode 100644 index 0000000000..0978519fe0 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type-fiberscope-php81.php @@ -0,0 +1,131 @@ += 8.1 + +namespace ClosurePassedToTypeFiberScope; + +use Closure; +use function PHPStan\Testing\assertType; + +/** + * Regression tests for closure parameter type inference in FiberScope. + * @see https://github.com/phpstan/phpstan/issues/13993 + * + * These tests verify that closure parameter types are properly inferred from + * expected callable types when using FiberScope. Since FiberScope requires + * PHP 8.1+ (Fibers), this file requires PHP 8.1+. + */ + +// ============================================================================ +// Example 1: Closure parameter inference with array destructuring +// ============================================================================ + +class DateRange +{ + public function format(): string + { + return '2024-01-01'; + } +} + +class Context {} + +class Loader +{ + /** + * @param Closure(Context, non-empty-array}>): iterable $loader + */ + public function __construct( + private Closure $loader, + ) {} +} + +/** + * Test: Closure parameter inference with array destructuring in constructor + * When a closure is passed to a constructor, the parameter types should be + * inferred from the expected Closure type, including array destructuring. + */ +$loader = new Loader( + loader: function (Context $context, array $items): iterable { + assertType('non-empty-array}>', $items); + foreach ($items as [$dateRange, $ids]) { + assertType('ClosurePassedToTypeFiberScope\DateRange', $dateRange); + assertType('list', $ids); + foreach ($ids as $id) { + assertType('int', $id); + yield [$id, $dateRange->format()] => 'value'; + } + } + }, +); + +// ============================================================================ +// Example 2: Generic callable parameter resolution +// ============================================================================ + +/** + * @template T + */ +class Vote +{ + /** + * @param T $subject + */ + public function __construct( + public bool $granted, + public mixed $subject, + ) {} +} + +/** + * @template TSubject + */ +class Decision +{ + /** + * @param list> $votes + */ + public function __construct( + private array $votes, + ) {} + + /** + * @template U + * @template K of array-key + * + * @param callable(Vote $vote): iterable $fn + * + * @return array + */ + public function collect(callable $fn): array + { + $result = []; + foreach ($this->votes as $vote) { + foreach ($fn($vote) as $key => $value) { + $result[$key] = $value; + } + } + return $result; + } +} + +class Subject +{ + public function id(): int + { + return 42; + } +} + +/** + * Test: Generic callable parameter resolution + * When passing a closure to Decision::collect(), + * the Vote parameter should be inferred as Vote. + */ +$decision = new Decision([new Vote(granted: true, subject: new Subject())]); +$result = $decision->collect(static function (Vote $vote): iterable { + assertType('ClosurePassedToTypeFiberScope\Vote', $vote); + assertType('ClosurePassedToTypeFiberScope\Subject', $vote->subject); + if ($vote->granted) { + yield $vote->subject->id() => $vote->subject; + } +}); +assertType('array', $result);