From 0264f5bc48448c7e02a23b82eef4177d0617a82f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 25 Jan 2023 16:43:32 +0100 Subject: [PATCH] Bleeding edge - IncompatibleDefaultParameterTypeRule for closures --- conf/bleedingEdge.neon | 1 + conf/config.level2.neon | 8 +++ conf/config.neon | 2 + src/Analyser/NodeScopeResolver.php | 6 +- src/Node/InArrowFunctionNode.php | 8 ++- ...eArrowFunctionDefaultParameterTypeRule.php | 65 +++++++++++++++++++ ...patibleClosureDefaultParameterTypeRule.php | 65 +++++++++++++++++++ ...owFunctionDefaultParameterTypeRuleTest.php | 33 ++++++++++ ...reFunctionDefaultParameterTypeRuleTest.php | 33 ++++++++++ ...default-parameter-type-arrow-functions.php | 16 +++++ ...patible-default-parameter-type-closure.php | 24 +++++++ 11 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 src/Rules/Functions/IncompatibleArrowFunctionDefaultParameterTypeRule.php create mode 100644 src/Rules/Functions/IncompatibleClosureDefaultParameterTypeRule.php create mode 100644 tests/PHPStan/Rules/Functions/IncompatibleArrowFunctionDefaultParameterTypeRuleTest.php create mode 100644 tests/PHPStan/Rules/Functions/IncompatibleClosureFunctionDefaultParameterTypeRuleTest.php create mode 100644 tests/PHPStan/Rules/Functions/data/incompatible-default-parameter-type-arrow-functions.php create mode 100644 tests/PHPStan/Rules/Functions/data/incompatible-default-parameter-type-closure.php diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index a45580e3e7..b7d5204369 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -28,3 +28,4 @@ parameters: alwaysTrueAlwaysReported: true disableUnreachableBranchesRules: true varTagType: true + closureDefaultParameterTypeRule: true diff --git a/conf/config.level2.neon b/conf/config.level2.neon index e2d86b9a27..377d1a605d 100644 --- a/conf/config.level2.neon +++ b/conf/config.level2.neon @@ -45,6 +45,10 @@ rules: - PHPStan\Rules\Properties\AccessPrivatePropertyThroughStaticRule conditionalTags: + PHPStan\Rules\Functions\IncompatibleArrowFunctionDefaultParameterTypeRule: + phpstan.rules.rule: %featureToggles.closureDefaultParameterTypeRule% + PHPStan\Rules\Functions\IncompatibleClosureDefaultParameterTypeRule: + phpstan.rules.rule: %featureToggles.closureDefaultParameterTypeRule% PHPStan\Rules\Methods\IllegalConstructorMethodCallRule: phpstan.rules.rule: %featureToggles.illegalConstructorMethodCall% PHPStan\Rules\Methods\IllegalConstructorStaticCallRule: @@ -59,6 +63,10 @@ services: checkClassCaseSensitivity: %checkClassCaseSensitivity% tags: - phpstan.rules.rule + - + class: PHPStan\Rules\Functions\IncompatibleArrowFunctionDefaultParameterTypeRule + - + class: PHPStan\Rules\Functions\IncompatibleClosureDefaultParameterTypeRule - class: PHPStan\Rules\Functions\CallCallablesRule arguments: diff --git a/conf/config.neon b/conf/config.neon index 130d9a1768..f582a64f8d 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -58,6 +58,7 @@ parameters: alwaysTrueAlwaysReported: false disableUnreachableBranchesRules: false varTagType: false + closureDefaultParameterTypeRule: false fileExtensions: - php checkAdvancedIsset: false @@ -283,6 +284,7 @@ parametersSchema: alwaysTrueAlwaysReported: bool() disableUnreachableBranchesRules: bool() varTagType: bool() + closureDefaultParameterTypeRule: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index bb6c64367e..17630f21dd 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3271,7 +3271,11 @@ private function processArrowFunctionNode( } $arrowFunctionScope = $scope->enterArrowFunction($expr, $callableParameters); - $nodeCallback(new InArrowFunctionNode($expr), $arrowFunctionScope); + $arrowFunctionType = $arrowFunctionScope->getAnonymousFunctionReflection(); + if (!$arrowFunctionType instanceof ClosureType) { + throw new ShouldNotHappenException(); + } + $nodeCallback(new InArrowFunctionNode($arrowFunctionType, $expr), $arrowFunctionScope); $this->processExprNode($expr->expr, $arrowFunctionScope, $nodeCallback, ExpressionContext::createTopLevel()); return new ExpressionResult($scope, false, []); diff --git a/src/Node/InArrowFunctionNode.php b/src/Node/InArrowFunctionNode.php index dd018f32d7..7716f332b4 100644 --- a/src/Node/InArrowFunctionNode.php +++ b/src/Node/InArrowFunctionNode.php @@ -5,6 +5,7 @@ use PhpParser\Node; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\NodeAbstract; +use PHPStan\Type\ClosureType; /** @api */ class InArrowFunctionNode extends NodeAbstract implements VirtualNode @@ -12,12 +13,17 @@ class InArrowFunctionNode extends NodeAbstract implements VirtualNode private Node\Expr\ArrowFunction $originalNode; - public function __construct(ArrowFunction $originalNode) + public function __construct(private ClosureType $closureType, ArrowFunction $originalNode) { parent::__construct($originalNode->getAttributes()); $this->originalNode = $originalNode; } + public function getClosureType(): ClosureType + { + return $this->closureType; + } + public function getOriginalNode(): Node\Expr\ArrowFunction { return $this->originalNode; diff --git a/src/Rules/Functions/IncompatibleArrowFunctionDefaultParameterTypeRule.php b/src/Rules/Functions/IncompatibleArrowFunctionDefaultParameterTypeRule.php new file mode 100644 index 0000000000..269581cac9 --- /dev/null +++ b/src/Rules/Functions/IncompatibleArrowFunctionDefaultParameterTypeRule.php @@ -0,0 +1,65 @@ + + */ +class IncompatibleArrowFunctionDefaultParameterTypeRule implements Rule +{ + + public function getNodeType(): string + { + return InArrowFunctionNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $parameters = $node->getClosureType()->getParameters(); + + $errors = []; + foreach ($node->getOriginalNode()->getParams() as $paramI => $param) { + if ($param->default === null) { + continue; + } + if ( + $param->var instanceof Node\Expr\Error + || !is_string($param->var->name) + ) { + throw new ShouldNotHappenException(); + } + + $defaultValueType = $scope->getType($param->default); + $parameterType = $parameters[$paramI]->getType(); + $parameterType = TemplateTypeHelper::resolveToBounds($parameterType); + + if ($parameterType->accepts($defaultValueType, true)->yes()) { + continue; + } + + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($parameterType, $defaultValueType); + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Default value of the parameter #%d $%s (%s) of anonymous function is incompatible with type %s.', + $paramI + 1, + $param->var->name, + $defaultValueType->describe($verbosityLevel), + $parameterType->describe($verbosityLevel), + ))->line($param->getLine())->build(); + } + + return $errors; + } + +} diff --git a/src/Rules/Functions/IncompatibleClosureDefaultParameterTypeRule.php b/src/Rules/Functions/IncompatibleClosureDefaultParameterTypeRule.php new file mode 100644 index 0000000000..89ad72ff25 --- /dev/null +++ b/src/Rules/Functions/IncompatibleClosureDefaultParameterTypeRule.php @@ -0,0 +1,65 @@ + + */ +class IncompatibleClosureDefaultParameterTypeRule implements Rule +{ + + public function getNodeType(): string + { + return InClosureNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $parameters = $node->getClosureType()->getParameters(); + + $errors = []; + foreach ($node->getOriginalNode()->getParams() as $paramI => $param) { + if ($param->default === null) { + continue; + } + if ( + $param->var instanceof Node\Expr\Error + || !is_string($param->var->name) + ) { + throw new ShouldNotHappenException(); + } + + $defaultValueType = $scope->getType($param->default); + $parameterType = $parameters[$paramI]->getType(); + $parameterType = TemplateTypeHelper::resolveToBounds($parameterType); + + if ($parameterType->accepts($defaultValueType, true)->yes()) { + continue; + } + + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($parameterType, $defaultValueType); + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Default value of the parameter #%d $%s (%s) of anonymous function is incompatible with type %s.', + $paramI + 1, + $param->var->name, + $defaultValueType->describe($verbosityLevel), + $parameterType->describe($verbosityLevel), + ))->line($param->getLine())->build(); + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Rules/Functions/IncompatibleArrowFunctionDefaultParameterTypeRuleTest.php b/tests/PHPStan/Rules/Functions/IncompatibleArrowFunctionDefaultParameterTypeRuleTest.php new file mode 100644 index 0000000000..9ec40a74a9 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/IncompatibleArrowFunctionDefaultParameterTypeRuleTest.php @@ -0,0 +1,33 @@ + + */ +class IncompatibleArrowFunctionDefaultParameterTypeRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new IncompatibleArrowFunctionDefaultParameterTypeRule(); + } + + public function testRule(): void + { + if (PHP_VERSION_ID < 70400) { + $this->markTestSkipped('Test requires PHP 7.4.'); + } + $this->analyse([__DIR__ . '/data/incompatible-default-parameter-type-arrow-functions.php'], [ + [ + 'Default value of the parameter #1 $i (string) of anonymous function is incompatible with type int.', + 13, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Functions/IncompatibleClosureFunctionDefaultParameterTypeRuleTest.php b/tests/PHPStan/Rules/Functions/IncompatibleClosureFunctionDefaultParameterTypeRuleTest.php new file mode 100644 index 0000000000..8d0506f9fc --- /dev/null +++ b/tests/PHPStan/Rules/Functions/IncompatibleClosureFunctionDefaultParameterTypeRuleTest.php @@ -0,0 +1,33 @@ + + */ +class IncompatibleClosureFunctionDefaultParameterTypeRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new IncompatibleClosureDefaultParameterTypeRule(); + } + + public function testRule(): void + { + if (PHP_VERSION_ID < 70400) { + $this->markTestSkipped('Test requires PHP 7.4.'); + } + $this->analyse([__DIR__ . '/data/incompatible-default-parameter-type-closure.php'], [ + [ + 'Default value of the parameter #1 $i (string) of anonymous function is incompatible with type int.', + 19, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/incompatible-default-parameter-type-arrow-functions.php b/tests/PHPStan/Rules/Functions/data/incompatible-default-parameter-type-arrow-functions.php new file mode 100644 index 0000000000..1623621c08 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/incompatible-default-parameter-type-arrow-functions.php @@ -0,0 +1,16 @@ += 7.4 + +namespace IncompatibleArrowFunctionDefaultParameterType; + +class Foo +{ + + public function doFoo(): void + { + $f = fn (int $i = null) => '1'; + $g = fn (?int $i = null) => '1'; + $h = fn (int $i = 5) => '1'; + $i = fn (int $i = 'foo') => '1'; + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/incompatible-default-parameter-type-closure.php b/tests/PHPStan/Rules/Functions/data/incompatible-default-parameter-type-closure.php new file mode 100644 index 0000000000..6037f3f707 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/incompatible-default-parameter-type-closure.php @@ -0,0 +1,24 @@ += 7.4 + +namespace IncompatibleClosureDefaultParameterType; + +class Foo +{ + + public function doFoo(): void + { + $f = function (int $i = null) { + return '1'; + }; + $g = function (?int $i = null) { + return '1'; + }; + $h = function (int $i = 5) { + return '1'; + }; + $i = function (int $i = 'foo') { + return '1'; + }; + } + +}