Skip to content
Open
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
92 changes: 92 additions & 0 deletions src/Analyser/ArgumentsNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions src/Analyser/ExprHandler/FuncCallHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions src/Rules/Functions/CallUserFuncRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
}
Expand Down
98 changes: 98 additions & 0 deletions tests/PHPStan/Analyser/nsrt/forward-static-call-multi-level.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php declare(strict_types = 1);

namespace ForwardStaticCallMultiLevel;

use function PHPStan\Testing\assertType;

/**
* Multi-level hierarchy: Root -> 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']));
}

}
Loading
Loading