diff --git a/conf/config.neon b/conf/config.neon index 65cda918e7..2cce68d15a 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -69,6 +69,7 @@ parameters: reportPossiblyNonexistentGeneralArrayOffset: false reportPossiblyNonexistentConstantArrayOffset: false checkMissingOverrideMethodAttribute: false + reportPropertiesThatShouldBePromoted: false mixinExcludeClasses: [] scanFiles: [] scanDirectories: [] diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 9c962d67ed..c1602805a8 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -79,6 +79,7 @@ parametersSchema: reportPossiblyNonexistentGeneralArrayOffset: bool() reportPossiblyNonexistentConstantArrayOffset: bool() checkMissingOverrideMethodAttribute: bool() + reportPropertiesThatShouldBePromoted: bool() parallel: structure([ jobSize: int(), processTimeout: float(), diff --git a/src/Rules/Properties/ReportPropertiesThatShouldBePromotedRule.php b/src/Rules/Properties/ReportPropertiesThatShouldBePromotedRule.php new file mode 100644 index 0000000000..9bc09efecb --- /dev/null +++ b/src/Rules/Properties/ReportPropertiesThatShouldBePromotedRule.php @@ -0,0 +1,107 @@ + + */ +#[RegisteredRule(level: 0)] +final class ReportPropertiesThatShouldBePromotedRule implements Rule +{ + + public function __construct( + #[AutowiredParameter] + private bool $reportPropertiesThatShouldBePromoted, + ) + { + } + + public function getNodeType(): string + { + return ClassMethod::class; + } + + #[Override] + public function processNode(Node $node, Scope $scope): array + { + if ( + ! $this->reportPropertiesThatShouldBePromoted + || $node->name->toLowerString() !== '__construct' + || $node->params === null + ) { + return []; + } + $errors = []; + foreach ($node->params as $param) { + if ( + $param->isPromoted() + || $param->var instanceof Error + || $param->var->name instanceof Expr + || ! $this->assignsUnmodifiedVariableToProperty($param->var->name, $node) + ) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf('Property [%s] should be promoted.', $param->var->name)) + ->identifier('property.shouldBePromoted') + ->line($param->var->getStartLine()) + ->build(); + } + return $errors; + } + + private function assignsUnmodifiedVariableToProperty(string $variable, ClassMethod $node): bool + { + foreach ($node->getStmts() as $stmt) { + if (! $stmt instanceof Expression || ! $stmt->expr instanceof Assign) { + continue; + } + $var = $stmt->expr->var; + $expr = $stmt->expr->expr; + if (! $var instanceof Variable && ! $var instanceof PropertyFetch) { + continue; + } + if ($var instanceof Variable) { + // The variable has been modified, so can't promote it. + if ($var->name === $variable) { + return false; + } + continue; + } + if ( + ! $var->var instanceof Variable + || $var->var->name !== 'this' + || ! $var->name instanceof Identifier + || $var->name->toString() !== $variable + ) { + continue; + } + if (! $expr instanceof Variable) { + continue; + } + // The variable is being assigned to a property + // of the same name, safe to promote it. + if ($expr->name === $variable) { + return true; + } + } + return false; + } + +} diff --git a/tests/PHPStan/Rules/Properties/ReportPropertiesThatShouldBePromotedRuleTest.php b/tests/PHPStan/Rules/Properties/ReportPropertiesThatShouldBePromotedRuleTest.php new file mode 100644 index 0000000000..cec027dff0 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/ReportPropertiesThatShouldBePromotedRuleTest.php @@ -0,0 +1,31 @@ + + */ +class ReportPropertiesThatShouldBePromotedRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ReportPropertiesThatShouldBePromoted(true); + } + + #[RequiresPhp('>= 8.0')] + public function testRule(): void + { + $error = static fn (string $property) => sprintf('Property [%s] should be promoted.', $property); + + $this->analyse([__DIR__ . '/data/properties-that-should-be-promoted.php'], [ + [$error('name'), 20], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Properties/data/properties-that-should-be-promoted.php b/tests/PHPStan/Rules/Properties/data/properties-that-should-be-promoted.php new file mode 100644 index 0000000000..9a73e1e39c --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/properties-that-should-be-promoted.php @@ -0,0 +1,38 @@ + */ + public array $tags; + + /** @var array */ + public array $options; + + public int $count; + + public int $foo; + + public function __construct( + int $count, + ?string $name, + public ?string $email, + /** @var array */ + array $tags, + /** @var array */ + array $options, + int $bar, + ) { + $this->count = $count; + + $tags = array_values($tags); + $this->tags = $tags; + $this->options = array_filter($options); + $this->email ??= 'example@example.com'; + $this->name = $name ?? 'Default Name'; + $this->foo = $bar; + } +}