diff --git a/src/Analyser/ArgumentsNormalizer.php b/src/Analyser/ArgumentsNormalizer.php index ae6fb6511c..0cd686d164 100644 --- a/src/Analyser/ArgumentsNormalizer.php +++ b/src/Analyser/ArgumentsNormalizer.php @@ -3,12 +3,14 @@ namespace PHPStan\Analyser; use PhpParser\Node\Arg; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\New_; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Identifier; +use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Scalar\String_; use PHPStan\Node\Expr\TypeExpr; @@ -17,16 +19,19 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Constant\ConstantArrayType; +use PHPStan\Type\Constant\ConstantIntegerType; use function array_is_list; use function array_key_exists; use function array_keys; use function array_values; use function count; +use function explode; use function is_string; use function key; use function ksort; use function max; use function sprintf; +use function str_contains; /** * @api @@ -191,6 +196,93 @@ public static function reorderCallUserFuncArrayArguments( ), $acceptsNamedArguments]; } + /** + * @return array{ParametersAcceptor, FuncCall|StaticCall, TrinaryLogic}|null + */ + public static function reorderForwardStaticCallArguments( + FuncCall $forwardStaticCallCall, + Scope $scope, + ): ?array + { + $result = self::reorderCallUserFuncArguments($forwardStaticCallCall, $scope); + if ($result === null) { + return null; + } + + [$parametersAcceptor, $innerFuncCall, $acceptsNamedArguments] = $result; + + return [ + $parametersAcceptor, + self::createForwardingStaticCall($innerFuncCall, $scope) ?? $innerFuncCall, + $acceptsNamedArguments, + ]; + } + + /** + * @return array{ParametersAcceptor, FuncCall|StaticCall, TrinaryLogic}|null + */ + public static function reorderForwardStaticCallArrayArguments( + FuncCall $forwardStaticCallArrayCall, + Scope $scope, + ): ?array + { + $result = self::reorderCallUserFuncArrayArguments($forwardStaticCallArrayCall, $scope); + if ($result === null) { + return null; + } + + [$parametersAcceptor, $innerFuncCall, $acceptsNamedArguments] = $result; + + return [ + $parametersAcceptor, + self::createForwardingStaticCall($innerFuncCall, $scope) ?? $innerFuncCall, + $acceptsNamedArguments, + ]; + } + + /** + * Turns a normalized callable invocation into a static call marked as a forwarding call + * (a call that forwards the caller's late static binding, like self:: and parent:: do), + * when the callable names a class and a method. Returns null for other callables. + */ + private static function createForwardingStaticCall(FuncCall $funcCall, Scope $scope): ?StaticCall + { + $callableExpr = $funcCall->name; + if (!$callableExpr instanceof Expr) { + return null; + } + + $callableType = $scope->getType($callableExpr); + + $className = null; + $methodName = null; + $constantArrays = $callableType->getConstantArrays(); + $constantStrings = $callableType->getConstantStrings(); + if (count($constantArrays) === 1) { + $classStrings = $constantArrays[0]->getOffsetValueType(new ConstantIntegerType(0))->getConstantStrings(); + $methodStrings = $constantArrays[0]->getOffsetValueType(new ConstantIntegerType(1))->getConstantStrings(); + if (count($classStrings) === 1 && count($methodStrings) === 1) { + $className = $classStrings[0]->getValue(); + $methodName = $methodStrings[0]->getValue(); + } + } elseif (count($constantStrings) === 1 && str_contains($constantStrings[0]->getValue(), '::')) { + [$className, $methodName] = explode('::', $constantStrings[0]->getValue(), 2); + } + + if ($className === null || $className === '' || $methodName === null || $methodName === '') { + return null; + } + + // The original call's position attributes are propagated to the name nodes too, + // so rule errors about the synthesized call point at the original call site. + return new StaticCall( + new FullyQualified($className, $funcCall->getAttributes()), + new Identifier($methodName, $funcCall->getAttributes()), + $funcCall->getArgs(), + $funcCall->getAttributes(), + ); + } + public static function reorderFuncArguments( ParametersAcceptor $parametersAcceptor, FuncCall $functionCall, diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 47efb2af7f..3d6eb480c8 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -9,7 +9,9 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\PropertyFetch; +use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; @@ -18,6 +20,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\OutputBufferHelper; use PHPStan\Analyser\ExprHandler\Helper\VoidToNullTypeTransformer; use PHPStan\Analyser\ImpurePoint; @@ -92,6 +95,7 @@ public function __construct( private ReflectionProvider $reflectionProvider, private DynamicThrowTypeExtensionProvider $dynamicThrowTypeExtensionProvider, private DynamicReturnTypeExtensionRegistryProvider $dynamicReturnTypeExtensionRegistryProvider, + private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private bool $implicitThrows, #[AutowiredParameter] @@ -867,6 +871,24 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type } } + if ($functionReflection->getName() === 'forward_static_call') { + $result = ArgumentsNormalizer::reorderForwardStaticCallArguments($expr, $scope); + if ($result !== null) { + [, $innerCall] = $result; + + return $this->resolveForwardStaticCallType($scope, $innerCall); + } + } + + if ($functionReflection->getName() === 'forward_static_call_array') { + $result = ArgumentsNormalizer::reorderForwardStaticCallArrayArguments($expr, $scope); + if ($result !== null) { + [, $innerCall] = $result; + + return $this->resolveForwardStaticCallType($scope, $innerCall); + } + } + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( $scope, $expr->getArgs(), @@ -908,6 +930,41 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $expr); } + /** + * forward_static_call() is a forwarding call: it keeps the caller's late static binding + * (like self:: and parent:: do). The named class is therefore resolved without resetting + * static:: to it — resolveTypeByName() already yields the caller-bound ancestor type when + * the named class is an ancestor, and a plain object type otherwise, matching the runtime + * behavior of only forwarding within the caller's own ancestry. + * + * The synthesized static call is resolved here directly instead of through + * MutatingScope::getType(), because it would be indistinguishable from a real, + * non-forwarding static call on the same class in the scope's expression-type cache. + */ + private function resolveForwardStaticCallType(MutatingScope $scope, FuncCall|StaticCall $innerCall): Type + { + if ( + $innerCall instanceof StaticCall + && $innerCall->class instanceof Name + && $innerCall->name instanceof Identifier + ) { + $callType = $this->methodCallReturnTypeHelper->methodCallReturnType( + $scope, + $scope->resolveTypeByName($innerCall->class), + $innerCall->name->toString(), + $innerCall, + ); + if ($callType !== null) { + return $callType; + } + + return new ErrorType(); + } + + // Closures and other callables have their own lexical scope; resolve them as-is. + return $scope->getType($innerCall); + } + public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes { if ($expr->name instanceof Name) { diff --git a/src/Rules/Functions/CallUserFuncRule.php b/src/Rules/Functions/CallUserFuncRule.php index cfc8f1955b..64e7accaab 100644 --- a/src/Rules/Functions/CallUserFuncRule.php +++ b/src/Rules/Functions/CallUserFuncRule.php @@ -62,6 +62,16 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $node, $scope, ); + } elseif ($functionName === 'forward_static_call') { + $result = ArgumentsNormalizer::reorderForwardStaticCallArguments( + $node, + $scope, + ); + } elseif ($functionName === 'forward_static_call_array') { + $result = ArgumentsNormalizer::reorderForwardStaticCallArrayArguments( + $node, + $scope, + ); } else { return []; } diff --git a/tests/PHPStan/Analyser/nsrt/forward-static-call-multi-level.php b/tests/PHPStan/Analyser/nsrt/forward-static-call-multi-level.php new file mode 100644 index 0000000000..02cf016198 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/forward-static-call-multi-level.php @@ -0,0 +1,98 @@ + Middle -> Leaf (final). + * Each class has its own which() implementation with a distinct return type, so the + * assertions below can tell exactly which implementation the call is resolved against. + */ +class Root +{ + + /** @return 'Root' */ + public static function which(): string + { + return 'Root'; + } + + /** @return static */ + public static function create(): static + { + return new static(); // @phpstan-ignore new.static + } + +} + +class Middle extends Root +{ + + /** @return 'Middle' */ + public static function which(): string // @phpstan-ignore method.childReturnType + { + return 'Middle'; + } + + /** @return static */ + public static function make(): static + { + return new static(); // @phpstan-ignore new.static + } + + public static function test(): void + { + // Naming an ancestor calls the *named* class's implementation (runtime returns + // 'Root' even though Middle and Leaf override which()), while the late static + // binding is forwarded. + assertType("'Root'", forward_static_call([Root::class, 'which'])); + assertType("'Root'", forward_static_call('ForwardStaticCallMultiLevel\Root::which')); + + // The forwarded binding is the caller's static: at runtime Middle::test() gives + // Middle and Leaf::test() gives Leaf, both covered by static(Middle). + assertType('static(ForwardStaticCallMultiLevel\Middle)', forward_static_call([Root::class, 'create'])); + assertType('static(ForwardStaticCallMultiLevel\Middle)', forward_static_call('ForwardStaticCallMultiLevel\Root::create')); + + // self::class names this very class; its own implementation is called. + assertType("'Middle'", forward_static_call([self::class, 'which'])); + assertType('static(ForwardStaticCallMultiLevel\Middle)', forward_static_call([self::class, 'make'])); + + // Naming a descendant: at runtime the binding is forwarded only when the caller's + // runtime static is a subclass of the named class (Leaf::test() gives Leaf, + // Middle::test() gives Leaf too because the binding resets to the named class), + // so the named class's object type covers both outcomes. + assertType('ForwardStaticCallMultiLevel\Leaf', forward_static_call([Leaf::class, 'create'])); + } + + public function instanceContext(): void + { + // An instance method also has an active class scope to forward + // (at runtime the binding is the object's class). + assertType('static(ForwardStaticCallMultiLevel\Middle)', forward_static_call([Root::class, 'create'])); + } + +} + +final class Leaf extends Middle +{ + + /** @return 'Leaf' */ + public static function which(): string // @phpstan-ignore method.childReturnType + { + return 'Leaf'; + } + + public static function test(): void + { + // Called from the final class, naming the two-levels-up ancestor: still the named + // class's implementation (runtime returns 'Root', not this class's override), and + // the forwarded binding collapses to the final class itself. + assertType("'Root'", forward_static_call([Root::class, 'which'])); + assertType('ForwardStaticCallMultiLevel\Leaf', forward_static_call([Root::class, 'create'])); + + // A method defined only in the intermediate class forwards the same way. + assertType('ForwardStaticCallMultiLevel\Leaf', forward_static_call([Middle::class, 'make'])); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/forward-static-call.php b/tests/PHPStan/Analyser/nsrt/forward-static-call.php new file mode 100644 index 0000000000..6e6eb4f084 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/forward-static-call.php @@ -0,0 +1,63 @@ + 1)); + } + +} + +class Other +{ + + /** @return static */ + public static function create(): static + { + return new static(); // @phpstan-ignore new.static + } + +} + +function outsideClass(): void { + // Runtime would throw (no class scope is active), but the type is still resolvable. + assertType('ForwardStaticCall\Base', forward_static_call([Base::class, 'create'])); +} diff --git a/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php b/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php index a05d57f945..5e6b182a83 100644 --- a/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php @@ -45,6 +45,32 @@ protected function getRule(): Rule ); } + public function testForwardStaticCall(): void + { + $this->analyse([__DIR__ . '/data/forward-static-call.php'], [ + [ + 'Callable passed to forward_static_call() invoked with 0 parameters, 1 required.', + 21, + ], + [ + 'Parameter #1 $name of callable passed to forward_static_call() expects string, int given.', + 22, + ], + [ + 'Callable passed to forward_static_call() invoked with 0 parameters, 1 required.', + 23, + ], + [ + 'Callable passed to forward_static_call_array() invoked with 0 parameters, 1 required.', + 25, + ], + [ + 'Parameter #1 $name of callable passed to forward_static_call_array() expects string, int given.', + 26, + ], + ]); + } + #[RequiresPhp('>= 8.0.0')] public function testRule(): void { diff --git a/tests/PHPStan/Rules/Functions/data/forward-static-call.php b/tests/PHPStan/Rules/Functions/data/forward-static-call.php new file mode 100644 index 0000000000..d1109a16b1 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/forward-static-call.php @@ -0,0 +1,29 @@ +