diff --git a/conf/config.neon b/conf/config.neon index b9f6445b08..7733b1b87b 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -606,6 +606,10 @@ services: class: PHPStan\DependencyInjection\Type\ParameterClosureTypeExtensionProvider factory: PHPStan\DependencyInjection\Type\LazyParameterClosureTypeExtensionProvider + - + class: PHPStan\DependencyInjection\Type\DynamicParameterTypeExtensionProvider + factory: PHPStan\DependencyInjection\Type\LazyDynamicParameterTypeExtensionProvider + - class: PHPStan\File\FileHelper arguments: diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d5fde30813..10c90f614d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -63,6 +63,7 @@ use PHPStan\BetterReflection\SourceLocator\Ast\Strategy\NodeToReflection; use PHPStan\BetterReflection\SourceLocator\Located\LocatedSource; use PHPStan\DependencyInjection\Reflection\ClassReflectionExtensionRegistryProvider; +use PHPStan\DependencyInjection\Type\DynamicParameterTypeExtensionProvider; use PHPStan\DependencyInjection\Type\DynamicThrowTypeExtensionProvider; use PHPStan\DependencyInjection\Type\ParameterClosureTypeExtensionProvider; use PHPStan\DependencyInjection\Type\ParameterOutTypeExtensionProvider; @@ -262,6 +263,7 @@ public function __construct( private readonly DynamicThrowTypeExtensionProvider $dynamicThrowTypeExtensionProvider, private readonly ReadWritePropertiesExtensionProvider $readWritePropertiesExtensionProvider, private readonly ParameterClosureTypeExtensionProvider $parameterClosureTypeExtensionProvider, + private readonly DynamicParameterTypeExtensionProvider $dynamicParameterTypeExtensionProvider, private readonly ScopeFactory $scopeFactory, private readonly bool $polluteScopeWithLoopInitialAssignments, private readonly bool $polluteScopeWithAlwaysIterableForeach, @@ -5002,6 +5004,12 @@ private function processArgs( if ($overwritingParameterType !== null) { $parameterType = $overwritingParameterType; + } else { + $overwritingParameterType = $this->getDynamicParameterTypeFromParameterTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); + + if ($overwritingParameterType !== null) { + $parameterType = $overwritingParameterType; + } } } @@ -5054,6 +5062,12 @@ private function processArgs( if ($overwritingParameterType !== null) { $parameterType = $overwritingParameterType; + } else { + $overwritingParameterType = $this->getDynamicParameterTypeFromParameterTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); + + if ($overwritingParameterType !== null) { + $parameterType = $overwritingParameterType; + } } } @@ -5065,6 +5079,15 @@ private function processArgs( } } else { $exprType = $scope->getType($arg->value); + + if ($parameter !== null) { + $overwritingParameterType = $this->getDynamicParameterTypeFromParameterTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); + + if ($overwritingParameterType !== null) { + $exprType = $overwritingParameterType; + } + } + $exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $nodeCallback, $context->enterDeep()); $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); @@ -5225,6 +5248,36 @@ private function getParameterTypeFromParameterClosureTypeExtension(CallLike $cal return null; } + /** + * @param MethodReflection|FunctionReflection|null $calleeReflection + */ + private function getDynamicParameterTypeFromParameterTypeExtension(CallLike $callLike, $calleeReflection, ParameterReflection $parameter, MutatingScope $scope): ?Type + { + if ($callLike instanceof FuncCall && $calleeReflection instanceof FunctionReflection) { + foreach ($this->dynamicParameterTypeExtensionProvider->getFunctionDynamicParameterTypeExtensions() as $functionDynamicParameterTypeExtension) { + if ($functionDynamicParameterTypeExtension->isFunctionSupported($calleeReflection, $parameter)) { + return $functionDynamicParameterTypeExtension->getTypeFromFunctionCall($calleeReflection, $callLike, $parameter, $scope); + } + } + } elseif ($calleeReflection instanceof MethodReflection) { + if ($callLike instanceof StaticCall) { + foreach ($this->dynamicParameterTypeExtensionProvider->getStaticMethodDynamicParameterTypeExtensions() as $staticMethodDynamicParameterTypeExtension) { + if ($staticMethodDynamicParameterTypeExtension->isStaticMethodSupported($calleeReflection, $parameter)) { + return $staticMethodDynamicParameterTypeExtension->getTypeFromStaticMethodCall($calleeReflection, $callLike, $parameter, $scope); + } + } + } elseif ($callLike instanceof MethodCall) { + foreach ($this->dynamicParameterTypeExtensionProvider->getMethodDynamicParameterTypeExtensions() as $methodDynamicParameterTypeExtension) { + if ($methodDynamicParameterTypeExtension->isMethodSupported($calleeReflection, $parameter)) { + return $methodDynamicParameterTypeExtension->getTypeFromMethodCall($calleeReflection, $callLike, $parameter, $scope); + } + } + } + } + + return null; + } + /** * @param MethodReflection|FunctionReflection|null $calleeReflection */ diff --git a/src/DependencyInjection/Type/DynamicParameterTypeExtensionProvider.php b/src/DependencyInjection/Type/DynamicParameterTypeExtensionProvider.php new file mode 100644 index 0000000000..78801ce192 --- /dev/null +++ b/src/DependencyInjection/Type/DynamicParameterTypeExtensionProvider.php @@ -0,0 +1,21 @@ +container->getServicesByTag(self::FUNCTION_TAG); + } + + public function getMethodDynamicParameterTypeExtensions(): array + { + return $this->container->getServicesByTag(self::METHOD_TAG); + } + + public function getStaticMethodDynamicParameterTypeExtensions(): array + { + return $this->container->getServicesByTag(self::STATIC_METHOD_TAG); + } + +} diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index b40a8ebca0..2b6ab0db46 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -15,6 +15,7 @@ use PHPStan\Collectors\Collector; use PHPStan\Collectors\Registry as CollectorRegistry; use PHPStan\Dependency\DependencyResolver; +use PHPStan\DependencyInjection\Type\DynamicParameterTypeExtensionProvider; use PHPStan\DependencyInjection\Type\DynamicThrowTypeExtensionProvider; use PHPStan\DependencyInjection\Type\ParameterClosureTypeExtensionProvider; use PHPStan\DependencyInjection\Type\ParameterOutTypeExtensionProvider; @@ -98,6 +99,7 @@ private function getAnalyser(DirectRuleRegistry $ruleRegistry): Analyser self::getContainer()->getByType(DynamicThrowTypeExtensionProvider::class), $readWritePropertiesExtensions !== [] ? new DirectReadWritePropertiesExtensionProvider($readWritePropertiesExtensions) : self::getContainer()->getByType(ReadWritePropertiesExtensionProvider::class), self::getContainer()->getByType(ParameterClosureTypeExtensionProvider::class), + self::getContainer()->getByType(DynamicParameterTypeExtensionProvider::class), self::createScopeFactory($reflectionProvider, $typeSpecifier), $this->shouldPolluteScopeWithLoopInitialAssignments(), $this->shouldPolluteScopeWithAlwaysIterableForeach(), diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php index da7cbe82c2..ce58ff701e 100644 --- a/src/Testing/TypeInferenceTestCase.php +++ b/src/Testing/TypeInferenceTestCase.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\ScopeContext; +use PHPStan\DependencyInjection\Type\DynamicParameterTypeExtensionProvider; use PHPStan\DependencyInjection\Type\DynamicThrowTypeExtensionProvider; use PHPStan\DependencyInjection\Type\ParameterClosureTypeExtensionProvider; use PHPStan\DependencyInjection\Type\ParameterOutTypeExtensionProvider; @@ -78,6 +79,7 @@ public static function processFile( self::getContainer()->getByType(DynamicThrowTypeExtensionProvider::class), self::getContainer()->getByType(ReadWritePropertiesExtensionProvider::class), self::getContainer()->getByType(ParameterClosureTypeExtensionProvider::class), + self::getContainer()->getByType(DynamicParameterTypeExtensionProvider::class), self::createScopeFactory($reflectionProvider, $typeSpecifier), self::getContainer()->getParameter('polluteScopeWithLoopInitialAssignments'), self::getContainer()->getParameter('polluteScopeWithAlwaysIterableForeach'), diff --git a/src/Type/FunctionDynamicParameterTypeExtension.php b/src/Type/FunctionDynamicParameterTypeExtension.php new file mode 100644 index 0000000000..9d37b8049d --- /dev/null +++ b/src/Type/FunctionDynamicParameterTypeExtension.php @@ -0,0 +1,32 @@ +getByType(DynamicThrowTypeExtensionProvider::class), self::getContainer()->getByType(ReadWritePropertiesExtensionProvider::class), self::getContainer()->getByType(ParameterClosureTypeExtensionProvider::class), + self::getContainer()->getByType(DynamicParameterTypeExtensionProvider::class), self::createScopeFactory($reflectionProvider, $typeSpecifier), false, true, diff --git a/tests/PHPStan/Analyser/DynamicParameterTypeExtensionTest.php b/tests/PHPStan/Analyser/DynamicParameterTypeExtensionTest.php new file mode 100644 index 0000000000..ab77643b82 --- /dev/null +++ b/tests/PHPStan/Analyser/DynamicParameterTypeExtensionTest.php @@ -0,0 +1,46 @@ +gatherAssertTypes(__DIR__ . '/data/dynamic-parameter-type-extension-arrow-function.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/dynamic-parameter-type-extension-closure.php'); + // yield from $this->gatherAssertTypes(__DIR__ . '/data/dynamic-parameter-type-extension-non-closure.php'); + } + + /** + * @dataProvider dataFileAsserts + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args, + ): void + { + if (PHP_VERSION_ID < 70400) { + $this->markTestSkipped('Test requires PHP 7.4.'); + } + + $this->assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/dynamic-parameter-type-extension.neon', + ]; + } + +} diff --git a/tests/PHPStan/Analyser/data/dynamic-parameter-type-extension-arrow-function.php b/tests/PHPStan/Analyser/data/dynamic-parameter-type-extension-arrow-function.php new file mode 100644 index 0000000000..c1590cd202 --- /dev/null +++ b/tests/PHPStan/Analyser/data/dynamic-parameter-type-extension-arrow-function.php @@ -0,0 +1,180 @@ +getName() === 'DynamicParameterTypeExtensionArrowFunction\functionWithCallable'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + ParameterReflection $parameter, + Scope $scope + ): ?Type { + $args = $functionCall->getArgs(); + + if (count($args) < 2) { + return null; + } + + $integer = $scope->getType($args[0]->value)->getConstantScalarValues()[0]; + + if ($integer === 1) { + return new ClosureType( + [ + new NativeParameterReflection('test', false, new GenericObjectType(Generic::class, [new IntegerType()]), PassedByReference::createNo(), false, null), + ], + new MixedType(), + ); + } + + return new ClosureType( + [ + new NativeParameterReflection('test', false, new GenericObjectType(Generic::class, [new StringType()]), PassedByReference::createNo(), false, null), + ], + new MixedType(), + ); + } +} + +class MethodDynamicParameterTypeExtension implements \PHPStan\Type\MethodDynamicParameterTypeExtension +{ + + public function isMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return $methodReflection->getDeclaringClass()->getName() === Foo::class && + $parameter->getName() === 'bar' && + $methodReflection->getName() === 'methodWithCallable'; + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + ParameterReflection $parameter, + Scope $scope + ): ?Type { + return new ClosureType( + [ + new NativeParameterReflection('bar', false, new GenericObjectType(Generic::class, [new StringType()]), PassedByReference::createNo(), false, null), + ], + new MixedType(), + ); + } +} + +class StaticMethodDynamicParameterTypeExtension implements \PHPStan\Type\StaticMethodDynamicParameterTypeExtension +{ + + public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return $methodReflection->getDeclaringClass()->getName() === Foo::class && $methodReflection->getName() === 'staticMethodWithCallable'; + } + + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + ParameterReflection $parameter, + Scope $scope + ): ?Type { + return new ClosureType( + [ + new NativeParameterReflection('test', false, new FloatType(), PassedByReference::createNo(), false, null), + ], + new MixedType() + ); + } +} + +class Foo +{ + + /** + * @param int $foo + * @param mixed $bar + * + * @return void + */ + public function methodWithCallable(int $foo, mixed $bar) + { + + } + + /** + * @return void + */ + public static function staticMethodWithCallable(callable $callback) + { + + } + +} + +/** + * @template T + */ +class Generic +{ + private $value; + + /** + * @param T $value + */ + public function __construct($value) + { + $this->value = $value; + } + + /** + * @return T + */ + public function getValue() + { + return $this->value; + } +} + +/** + * @param int $foo + * @param callable(Generic) $callback + * + * @return void + */ +function functionWithCallable(int $foo, callable $callback) +{ + +} + +function test(Foo $foo): void +{ + + (new Foo)->methodWithCallable(2, fn ($arg) => assertType('string', $arg->getValue())); + + Foo::staticMethodWithCallable(fn ($i) => assertType('float', $i)); + +} + +functionWithCallable(1, fn ($i) => assertType('int', $i->getValue())); +functionWithCallable(2, fn (Generic $i) => assertType('string', $i->getValue())); diff --git a/tests/PHPStan/Analyser/data/dynamic-parameter-type-extension-closure.php b/tests/PHPStan/Analyser/data/dynamic-parameter-type-extension-closure.php new file mode 100644 index 0000000000..27779b9166 --- /dev/null +++ b/tests/PHPStan/Analyser/data/dynamic-parameter-type-extension-closure.php @@ -0,0 +1,211 @@ +getName() === 'DynamicParameterTypeExtensionClosure\functionWithCallable'; + } + + public function getTypeFromFunctionCall( + FunctionReflection $functionReflection, + FuncCall $functionCall, + ParameterReflection $parameter, + Scope $scope + ): ?Type { + $args = $functionCall->getArgs(); + + if (count($args) < 2) { + return null; + } + + $integer = $scope->getType($args[0]->value)->getConstantScalarValues()[0]; + + if ($integer === 1) { + return new ClosureType( + [ + new NativeParameterReflection('test', false, new GenericObjectType(Generic::class, [new IntegerType()]), PassedByReference::createNo(), false, null), + ], + new MixedType(), + ); + } + + return new ClosureType( + [ + new NativeParameterReflection('test', false, new GenericObjectType(Generic::class, [new StringType()]), PassedByReference::createNo(), false, null), + ], + new MixedType(), + ); + } +} + +class MethodDynamicParameterTypeExtension implements \PHPStan\Type\MethodDynamicParameterTypeExtension +{ + + public function isMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return $methodReflection->getDeclaringClass()->getName() === Foo::class && + $parameter->getName() === 'relations' && + $methodReflection->getName() === 'with'; + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + ParameterReflection $parameter, + Scope $scope + ): ?Type { + return new ConstantArrayType([ + new ConstantStringType('user'), + ], [ + new ClosureType( + [ + new NativeParameterReflection( + 'callback', + false, + new GenericObjectType('Illuminate\Database\Eloquent\Builder', [ + new ObjectType('Illuminate\Database\Eloquent\Model'), + ]), + PassedByReference::createNo(), + false, + null, + ), + ], + new MixedType(), + ), + ]); + } +} + +class StaticMethodDynamicParameterTypeExtension implements \PHPStan\Type\StaticMethodDynamicParameterTypeExtension +{ + + public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return $methodReflection->getDeclaringClass()->getName() === Foo::class && $methodReflection->getName() === 'staticMethodWithCallable'; + } + + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + ParameterReflection $parameter, + Scope $scope + ): ?Type { + return new ClosureType( + [ + new NativeParameterReflection('test', false, new FloatType(), PassedByReference::createNo(), false, null), + ], + new MixedType() + ); + } +} + +class Foo +{ + + /** + * @param int $foo + * @param mixed $bar + * + * @return void + */ + public function methodWithCallable(int $foo, mixed $bar) + { + + } + + /** + * @return void + */ + public static function staticMethodWithCallable(callable $callback) + { + + } + + public function with(array $relations) + { + + } + +} + +/** + * @template T + */ +class Generic +{ + private $value; + + /** + * @param T $value + */ + public function __construct($value) + { + $this->value = $value; + } + + /** + * @return T + */ + public function getValue() + { + return $this->value; + } +} + +/** + * @param int $foo + * @param callable(Generic) $callback + * + * @return void + */ +function functionWithCallable(int $foo, callable $callback) +{ + +} + +function test(Foo $foo): void +{ + + (new Foo)->with([ + 'users' => function ($arg) { + assertType('Illuminate\Database\Eloquent\Builder', $arg); + }, + ]); + + Foo::staticMethodWithCallable(function ($i) { + assertType('float', $i); + }); + +} + +functionWithCallable(1, function ($i) { + assertType('int', $i->getValue()); +}); +functionWithCallable(2, function (Generic $i) { + assertType('string', $i->getValue()); +}); diff --git a/tests/PHPStan/Analyser/data/dynamic-parameter-type-extension-non-closure.php b/tests/PHPStan/Analyser/data/dynamic-parameter-type-extension-non-closure.php new file mode 100644 index 0000000000..5991ab7a3a --- /dev/null +++ b/tests/PHPStan/Analyser/data/dynamic-parameter-type-extension-non-closure.php @@ -0,0 +1,3 @@ +