From f545859db0276fea6f8455539729d2365a359cbf Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Fri, 9 Oct 2026 12:02:53 +0200 Subject: [PATCH 1/5] fix(generics): infer template arguments from callable returns --- .../Generics/TemplateArgumentObserver.php | 21 +++- ...losureSignatureFromUsagesToggleOffTest.php | 1 + .../Generics/TemplateArgumentFlowTest.php | 1 + .../Generics/TemplateArgumentResolverTest.php | 9 +- .../callable-return-template-inference.php | 98 +++++++++++++++++++ turbo-ext/src/TemplateArgumentObserver.cpp | 28 +++++- turbo-ext/tests/type-family.php | 44 +++++++++ 7 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 tests/PHPStan/Analyser/Generics/data/callable-return-template-inference.php diff --git a/src/Analyser/Generics/TemplateArgumentObserver.php b/src/Analyser/Generics/TemplateArgumentObserver.php index 43d9cfa68e4..d475a86be55 100644 --- a/src/Analyser/Generics/TemplateArgumentObserver.php +++ b/src/Analyser/Generics/TemplateArgumentObserver.php @@ -8,6 +8,7 @@ use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ResolvedFunctionVariant; use PHPStan\Turbo\ShadowedByTurboExtension; +use PHPStan\Type\CallableType; use PHPStan\Type\ClosureType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeHelper; @@ -606,9 +607,7 @@ private function observeLowerBound(TemplateArgumentConstraints $constraints, Typ // never is its own iterable key and value type return $constraints; } - if ($parameterType instanceof TemplateType || $parameterType->isCallable()->yes()) { - // callable parameters put the template in a contravariant position: - // what they say about it is an upper bound, not something flowing in + if ($parameterType instanceof TemplateType) { return $constraints; } if ($parameterType instanceof UnionType) { @@ -659,6 +658,22 @@ private function observeLowerBound(TemplateArgumentConstraints $constraints, Typ return $constraints; } + if (($parameterType instanceof CallableType || $parameterType instanceof ClosureType) && $argumentType->isCallable()->yes()) { + $scope = new OutOfClassScope(); + foreach ($parameterType->getCallableParametersAcceptors($scope) as $parameterAcceptor) { + foreach ($argumentType->getCallableParametersAcceptors($scope) as $argumentAcceptor) { + // Returns flow into the callable; its parameters are contravariant. + $constraints = $this->observeLowerBound( + $constraints, + $parameterAcceptor->getReturnType(), + $argumentAcceptor->getReturnType(), + ); + } + } + + return $constraints; + } + $parameterReflections = $parameterType->getObjectClassReflections(); if (count($parameterReflections) === 1) { $parameterReflection = $parameterReflections[0]; diff --git a/tests/PHPStan/Analyser/Generics/ClosureSignatureFromUsagesToggleOffTest.php b/tests/PHPStan/Analyser/Generics/ClosureSignatureFromUsagesToggleOffTest.php index 5164408fa31..bcf7842dac4 100644 --- a/tests/PHPStan/Analyser/Generics/ClosureSignatureFromUsagesToggleOffTest.php +++ b/tests/PHPStan/Analyser/Generics/ClosureSignatureFromUsagesToggleOffTest.php @@ -19,6 +19,7 @@ public static function dataFileAsserts(): iterable { yield from self::gatherAssertTypes(__DIR__ . '/data/closure-signature-from-usages-off.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/closure-byref-uses-at-invocation-off.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/callable-return-template-inference.php'); } /** diff --git a/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowTest.php b/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowTest.php index 7d4ec97fd49..aaac838c767 100644 --- a/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowTest.php +++ b/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowTest.php @@ -18,6 +18,7 @@ public static function dataConstraintsSurviveControlFlow(): iterable if (PHP_VERSION_ID < 80000) { return; } + yield from self::gatherAssertTypes(__DIR__ . '/data/callable-return-template-inference.php'); yield from self::gatherAssertTypes(__DIR__ . '/../../Rules/Functions/data/joint-inference-named.php'); } diff --git a/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php b/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php index eb6e2d2ae33..2b20279b86e 100644 --- a/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php +++ b/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php @@ -4,6 +4,8 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\Variable; +use PHPStan\Reflection\Native\NativeParameterReflection; +use PHPStan\Reflection\PassedByReference; use PHPStan\Reflection\ExtendedFunctionVariant; use PHPStan\Reflection\ResolvedFunctionVariantWithOriginal; use PHPStan\Testing\PHPStanTestCase; @@ -11,6 +13,7 @@ use PHPStan\Type\CallableType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\FloatType; use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeFactory; @@ -125,10 +128,12 @@ public function testLowerBoundsUnionWithTheInitialUnlessASendWins(): void $constraints = $constraints->merge((new TemplateArgumentObserver())->collectArgument($marker, new ConstantIntegerType(2))); $constraints = $constraints->merge((new TemplateArgumentObserver())->collectArgument($marker, new ConstantStringType('a'))); $constraints = $constraints->merge((new TemplateArgumentObserver())->collectArgument(new ArrayType(new IntegerType(), $marker), new ArrayType(new IntegerType(), new NullType()))); - $constraints = $constraints->merge((new TemplateArgumentObserver())->collectArgument(new CallableType([], $marker, false), new CallableType([], new StringType(), false))); + $parameter = new NativeParameterReflection('value', false, $marker, PassedByReference::createNo(), false, null); + $argument = new NativeParameterReflection('value', false, new FloatType(), PassedByReference::createNo(), false, null); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectArgument(new CallableType([$parameter], $marker, false), new CallableType([$argument], new StringType(), false))); $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); - $this->assertSame("1|2|'a'|null", self::describe($frame->resolve($site, 'T')), 'callable parameters are contravariant and contribute nothing'); + $this->assertSame('1|2|string|null', self::describe($frame->resolve($site, 'T')), 'callable returns contribute lower bounds, but contravariant parameters do not'); [$constraints, $site, $ofMarker] = self::constraintsWithA(new ConstantIntegerType(1)); $constraints = $constraints->merge((new TemplateArgumentObserver())->collectArgument($ofMarker->getTypes()[0], new ConstantStringType('a'))); diff --git a/tests/PHPStan/Analyser/Generics/data/callable-return-template-inference.php b/tests/PHPStan/Analyser/Generics/data/callable-return-template-inference.php new file mode 100644 index 00000000000..c54644844ba --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/data/callable-return-template-inference.php @@ -0,0 +1,98 @@ += 8.0 +declare(strict_types = 1); + +namespace CallableReturnTemplateInference; + +use function PHPStan\Testing\assertType; + +/** + * @template K + * @template V + */ +class Pair +{ + + /** + * @param K $key + * @param V $value + */ + public function __construct(public mixed $key, public mixed $value) + { + } + +} + +/** + * @template K + * @template V + */ +class Map +{ +} + +/** + * @template K + * @template V + * @template KReturn + * @template VReturn + * @param iterable $items + * @param callable(K, V): Pair $mapper + * @return Map + */ +function mapItems(iterable $items, callable $mapper): Map +{ + return new Map(); +} + +/** @param array $items */ +function callbacks(array $items): void +{ + assertType('CallableReturnTemplateInference\Map', mapItems( + $items, + static fn (string $key, int $value) => new Pair($key, $value), + )); + + assertType('CallableReturnTemplateInference\Map', mapItems( + $items, + static function (string $key, int $value): Pair { + return new Pair($key, $value); + }, + )); + + assertType('CallableReturnTemplateInference\Map>', mapItems( + $items, + static fn (string $key, int $value) => new Pair($key, new Pair($value, $value)), + )); +} + +/** @template T */ +class Invoker +{ + + /** @param T $value */ + public function __construct(public mixed $value) + { + } + + /** @return self */ + public function __invoke(): self + { + return $this; + } + +} + +/** + * @template T + * @param Invoker $callback + * @return Invoker + */ +function passInvoker(Invoker $callback): Invoker +{ + return $callback; +} + +function recursiveCallable(int $value): void +{ + assertType('CallableReturnTemplateInference\Invoker', passInvoker(new Invoker($value))); +} diff --git a/turbo-ext/src/TemplateArgumentObserver.cpp b/turbo-ext/src/TemplateArgumentObserver.cpp index 265a244c6f9..884faba5c12 100644 --- a/turbo-ext/src/TemplateArgumentObserver.cpp +++ b/turbo-ext/src/TemplateArgumentObserver.cpp @@ -957,9 +957,6 @@ class TemplateArgumentObserver bool parameterTemplate; if (UNEXPECTED(!isTemplateType(parameterType, parameterTemplate))) return zv::Val(); if (parameterTemplate) return constraints; - zend_long callable = pt_type_op_trinary(Z_OBJ_P(parameterType), PT_OP_IS_CALLABLE, 0, NULL); - if (UNEXPECTED(callable < 0)) return zv::Val(); - if (callable == PT_TRI_YES) return constraints; if (isInstance(parameterType, pt_ce_union_type)) { zv::Val argumentMembers; @@ -1017,6 +1014,31 @@ class TemplateArgumentObserver return constraints; } + if (isInstance(parameterType, pt_ce_callable_type) || isInstance(parameterType, pt_ce_closure_type)) { + zend_long argumentCallable = pt_type_op_trinary(Z_OBJ_P(argumentType), PT_OP_IS_CALLABLE, 0, NULL); + if (UNEXPECTED(argumentCallable < 0)) return zv::Val(); + if (argumentCallable == PT_TRI_YES) { + zv::Val scope = pt_type_new(PT_CLASS_OUT_OF_CLASS_SCOPE, 0, NULL); + if (UNEXPECTED(scope.isUndef())) return zv::Val(); + zv::Val parameterAcceptors = pt_type_call(Z_OBJ_P(parameterType), PT_LC("getcallableparametersacceptors"), 1, scope.raw()); + if (UNEXPECTED(parameterAcceptors.isUndef())) return zv::Val(); + for (auto parameterEntry : zv::ArrRef(parameterAcceptors.raw())) { + zv::Val argumentAcceptors = pt_type_call(Z_OBJ_P(argumentType), PT_LC("getcallableparametersacceptors"), 1, scope.raw()); + if (UNEXPECTED(argumentAcceptors.isUndef())) return zv::Val(); + for (auto argumentEntry : zv::ArrRef(argumentAcceptors.raw())) { + // Returns flow into the callable; its parameters are contravariant. + zv::Val parameterReturn = pt_parameters_acceptor_call(parameterEntry.value().deref().raw(), PT_PA_GET_RETURN_TYPE); + if (UNEXPECTED(parameterReturn.isUndef())) return zv::Val(); + zv::Val argumentReturn = pt_parameters_acceptor_call(argumentEntry.value().deref().raw(), PT_PA_GET_RETURN_TYPE); + if (UNEXPECTED(argumentReturn.isUndef())) return zv::Val(); + constraints = observeLowerBound(std::move(constraints), parameterReturn.raw(), argumentReturn.raw()); + if (UNEXPECTED(constraints.isUndef())) return zv::Val(); + } + } + return constraints; + } + } + zv::Val parameterReflections = objectClassReflections(parameterType); if (UNEXPECTED(parameterReflections.isUndef())) return zv::Val(); uint32_t parameterReflectionCount = countOf(parameterReflections.raw()); diff --git a/turbo-ext/tests/type-family.php b/turbo-ext/tests/type-family.php index b3c2d16ab39..6dc37c84982 100644 --- a/turbo-ext/tests/type-family.php +++ b/turbo-ext/tests/type-family.php @@ -8172,6 +8172,50 @@ public function getResolvedPhpDoc(): ?\PHPStan\PhpDoc\ResolvedPhpDocBlock { retu $r["observer pure argument $declaredName <- $actualName"] = $tacCatching(static fn () => $viewConstraints($observer->collectArgument($declared, $actual, true))); } } + // Callable returns contribute bounds, but contravariant parameters do not. + $callableParameterMarker = new \PHPStan\Reflection\Php\DummyParameter( + 'value', $markers['m1U'], false, null, false, null, + ); + $callableArgumentParameter = new \PHPStan\Reflection\Php\DummyParameter( + 'value', $int, false, null, false, null, + ); + $callableReturns = [ + 'bare return marker' => [ + new \PHPStan\Type\CallableType([], $markers['m1T']), + new \PHPStan\Type\ClosureType([], $string), + ], + 'nested generic return marker' => [ + new \PHPStan\Type\ClosureType([], $ao($int, $markers['m1T'])), + new \PHPStan\Type\CallableType([], $ao($int, $string)), + ], + 'contravariant parameter marker' => [ + new \PHPStan\Type\CallableType([$callableParameterMarker], new \PHPStan\Type\VoidType()), + new \PHPStan\Type\ClosureType([$callableArgumentParameter], new \PHPStan\Type\VoidType()), + ], + 'return and contravariant parameter markers' => [ + new \PHPStan\Type\CallableType([$callableParameterMarker], $markers['m1T']), + new \PHPStan\Type\ClosureType([$callableArgumentParameter], $string), + ], + 'union of callable arguments' => [ + new \PHPStan\Type\CallableType([], $markers['m1T']), + new \PHPStan\Type\UnionType([ + new \PHPStan\Type\ClosureType([], $string), + new \PHPStan\Type\CallableType([], $int), + ]), + ], + 'union of callable parameters' => [ + new \PHPStan\Type\UnionType([ + new \PHPStan\Type\CallableType([], $markers['m1T']), + new \PHPStan\Type\NullType(), + ]), + new \PHPStan\Type\ClosureType([], $string), + ], + ]; + foreach ($callableReturns as $name => [$parameterType, $argumentType]) { + $r["observer callable argument $name"] = $tacCatching( + static fn () => $viewConstraints($observer->collectArgument($parameterType, $argumentType)), + ); + } $templateMap = new \PHPStan\Type\Generic\TemplateTypeMap(['T' => $tT, 'U' => $tU]); $parameters = [ new \PHPStan\Reflection\Php\DummyParameter('a', $ao($int, $tT), false, null, false, null), From ff009cdeb7a7fd21fb4bafb2902e7f43608821ef Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Fri, 9 Oct 2026 12:02:59 +0200 Subject: [PATCH 2/5] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 88c83cac563..90f3217609d 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '28c29b8'; + public const EXPECTED_EXTENSION_VERSION = 'f545859'; private static bool $active = false; From 4106a248ec1fc9a309ddccbdd37b5757c52d04c6 Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Fri, 9 Oct 2026 12:15:03 +0200 Subject: [PATCH 3/5] fix(generics): correct callable inference validation issues --- src/Analyser/Generics/TemplateArgumentObserver.php | 2 +- .../Generics/ClosureSignatureFromUsagesToggleOffTest.php | 5 ++++- .../Analyser/Generics/TemplateArgumentResolverTest.php | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Analyser/Generics/TemplateArgumentObserver.php b/src/Analyser/Generics/TemplateArgumentObserver.php index d475a86be55..0d8a0282aad 100644 --- a/src/Analyser/Generics/TemplateArgumentObserver.php +++ b/src/Analyser/Generics/TemplateArgumentObserver.php @@ -658,7 +658,7 @@ private function observeLowerBound(TemplateArgumentConstraints $constraints, Typ return $constraints; } - if (($parameterType instanceof CallableType || $parameterType instanceof ClosureType) && $argumentType->isCallable()->yes()) { + if (($parameterType instanceof CallableType || $parameterType instanceof ClosureType) && $argumentType->isCallable()->yes()) { // @phpstan-ignore phpstanApi.instanceofType (Traverse signatures only; nominal invokable objects can return themselves.) $scope = new OutOfClassScope(); foreach ($parameterType->getCallableParametersAcceptors($scope) as $parameterAcceptor) { foreach ($argumentType->getCallableParametersAcceptors($scope) as $argumentAcceptor) { diff --git a/tests/PHPStan/Analyser/Generics/ClosureSignatureFromUsagesToggleOffTest.php b/tests/PHPStan/Analyser/Generics/ClosureSignatureFromUsagesToggleOffTest.php index bcf7842dac4..44a327b5b99 100644 --- a/tests/PHPStan/Analyser/Generics/ClosureSignatureFromUsagesToggleOffTest.php +++ b/tests/PHPStan/Analyser/Generics/ClosureSignatureFromUsagesToggleOffTest.php @@ -5,6 +5,7 @@ use PHPStan\Testing\TypeInferenceTestCase; use PHPUnit\Framework\Attributes\DataProvider; use function array_merge; +use const PHP_VERSION_ID; /** * With unresolved template arguments on but closure signature inference off, @@ -19,7 +20,9 @@ public static function dataFileAsserts(): iterable { yield from self::gatherAssertTypes(__DIR__ . '/data/closure-signature-from-usages-off.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/closure-byref-uses-at-invocation-off.php'); - yield from self::gatherAssertTypes(__DIR__ . '/data/callable-return-template-inference.php'); + if (PHP_VERSION_ID >= 80000) { + yield from self::gatherAssertTypes(__DIR__ . '/data/callable-return-template-inference.php'); + } } /** diff --git a/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php b/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php index 2b20279b86e..adf0a85ed9e 100644 --- a/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php +++ b/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php @@ -4,9 +4,9 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\Variable; +use PHPStan\Reflection\ExtendedFunctionVariant; use PHPStan\Reflection\Native\NativeParameterReflection; use PHPStan\Reflection\PassedByReference; -use PHPStan\Reflection\ExtendedFunctionVariant; use PHPStan\Reflection\ResolvedFunctionVariantWithOriginal; use PHPStan\Testing\PHPStanTestCase; use PHPStan\Type\ArrayType; From 4c3fb7dda9a67a6b32108344df54b447e5f3368e Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Fri, 9 Oct 2026 12:19:07 +0200 Subject: [PATCH 4/5] chore(generics): correct spelling in inference comment --- src/Analyser/Generics/TemplateArgumentObserver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/Generics/TemplateArgumentObserver.php b/src/Analyser/Generics/TemplateArgumentObserver.php index 0d8a0282aad..e8b0e3a3cfd 100644 --- a/src/Analyser/Generics/TemplateArgumentObserver.php +++ b/src/Analyser/Generics/TemplateArgumentObserver.php @@ -658,7 +658,7 @@ private function observeLowerBound(TemplateArgumentConstraints $constraints, Typ return $constraints; } - if (($parameterType instanceof CallableType || $parameterType instanceof ClosureType) && $argumentType->isCallable()->yes()) { // @phpstan-ignore phpstanApi.instanceofType (Traverse signatures only; nominal invokable objects can return themselves.) + if (($parameterType instanceof CallableType || $parameterType instanceof ClosureType) && $argumentType->isCallable()->yes()) { // @phpstan-ignore phpstanApi.instanceofType (Traverse signatures only; nominal invocable objects can return themselves.) $scope = new OutOfClassScope(); foreach ($parameterType->getCallableParametersAcceptors($scope) as $parameterAcceptor) { foreach ($argumentType->getCallableParametersAcceptors($scope) as $argumentAcceptor) { From f9b4a9342ccb7c35b8219165795e2c319b6e265f Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Fri, 9 Oct 2026 12:24:56 +0200 Subject: [PATCH 5/5] test(generics): use early return for legacy PHP fixture guard --- .../Generics/ClosureSignatureFromUsagesToggleOffTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/Generics/ClosureSignatureFromUsagesToggleOffTest.php b/tests/PHPStan/Analyser/Generics/ClosureSignatureFromUsagesToggleOffTest.php index 44a327b5b99..629b023bd6c 100644 --- a/tests/PHPStan/Analyser/Generics/ClosureSignatureFromUsagesToggleOffTest.php +++ b/tests/PHPStan/Analyser/Generics/ClosureSignatureFromUsagesToggleOffTest.php @@ -20,9 +20,10 @@ public static function dataFileAsserts(): iterable { yield from self::gatherAssertTypes(__DIR__ . '/data/closure-signature-from-usages-off.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/closure-byref-uses-at-invocation-off.php'); - if (PHP_VERSION_ID >= 80000) { - yield from self::gatherAssertTypes(__DIR__ . '/data/callable-return-template-inference.php'); + if (PHP_VERSION_ID < 80000) { + return; } + yield from self::gatherAssertTypes(__DIR__ . '/data/callable-return-template-inference.php'); } /**