From 80c05e3971bbf9289b13b3caaba0b9da10188618 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 16 Sep 2026 16:52:42 +0200 Subject: [PATCH] [Php86] Add ConstructorReadonlyAssignToDefaultRector for readonly property defaults --- config/set/php-version-based.php | 2 + config/set/php86.php | 3 +- ...uctorReadonlyAssignToDefaultRectorTest.php | 28 +++ .../Fixture/class_const_default.php.inc | 34 +++ .../Fixture/fixture.php.inc | 30 +++ .../Fixture/skip_conditional_assign.php.inc | 17 ++ .../Fixture/skip_new_object.php.inc | 13 ++ .../Fixture/skip_not_readonly.php.inc | 13 ++ .../Fixture/skip_variable_assign.php.inc | 13 ++ .../config/configured_rule.php | 13 ++ ...nstructorReadonlyAssignToDefaultRector.php | 200 ++++++++++++++++++ src/ValueObject/PhpVersionFeature.php | 5 + 12 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/ConstructorReadonlyAssignToDefaultRectorTest.php create mode 100644 rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/class_const_default.php.inc create mode 100644 rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/fixture.php.inc create mode 100644 rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_conditional_assign.php.inc create mode 100644 rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_new_object.php.inc create mode 100644 rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_not_readonly.php.inc create mode 100644 rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_variable_assign.php.inc create mode 100644 rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/config/configured_rule.php create mode 100644 rules/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector.php diff --git a/config/set/php-version-based.php b/config/set/php-version-based.php index 42289f31469..89a3c2c4c2f 100644 --- a/config/set/php-version-based.php +++ b/config/set/php-version-based.php @@ -139,6 +139,7 @@ use Rector\Php85\Rector\Property\AddOverrideAttributeToOverriddenPropertiesRector; use Rector\Php85\Rector\ShellExec\ShellExecFunctionCallOverBackticksRector; use Rector\Php85\Rector\Switch_\ColonAfterSwitchCaseRector; +use Rector\Php86\Rector\Class_\ConstructorReadonlyAssignToDefaultRector; use Rector\Php86\Rector\FuncCall\MinMaxToClampRector; use Rector\Removing\Rector\FuncCall\RemoveFuncCallArgRector; use Rector\Removing\Rector\FuncCall\RemoveFuncCallRector; @@ -325,6 +326,7 @@ // PHP 8.6 MinMaxToClampRector::class, + ConstructorReadonlyAssignToDefaultRector::class, ]); // configured rules, each bound to the PHP version its configuration targets diff --git a/config/set/php86.php b/config/set/php86.php index 9eb067e51ea..dd823ce93b7 100644 --- a/config/set/php86.php +++ b/config/set/php86.php @@ -3,8 +3,9 @@ declare(strict_types=1); use Rector\Config\RectorConfig; +use Rector\Php86\Rector\Class_\ConstructorReadonlyAssignToDefaultRector; use Rector\Php86\Rector\FuncCall\MinMaxToClampRector; return static function (RectorConfig $rectorConfig): void { - $rectorConfig->rules([MinMaxToClampRector::class]); + $rectorConfig->rules([MinMaxToClampRector::class, ConstructorReadonlyAssignToDefaultRector::class]); }; diff --git a/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/ConstructorReadonlyAssignToDefaultRectorTest.php b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/ConstructorReadonlyAssignToDefaultRectorTest.php new file mode 100644 index 00000000000..f2e442a1e36 --- /dev/null +++ b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/ConstructorReadonlyAssignToDefaultRectorTest.php @@ -0,0 +1,28 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/class_const_default.php.inc b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/class_const_default.php.inc new file mode 100644 index 00000000000..ca1141a8b5c --- /dev/null +++ b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/class_const_default.php.inc @@ -0,0 +1,34 @@ +name = self::DEFAULT_NAME; + } +} + +?> +----- + diff --git a/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/fixture.php.inc b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/fixture.php.inc new file mode 100644 index 00000000000..8732e0c48bb --- /dev/null +++ b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/fixture.php.inc @@ -0,0 +1,30 @@ +number = 100; + } +} + +?> +----- + diff --git a/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_conditional_assign.php.inc b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_conditional_assign.php.inc new file mode 100644 index 00000000000..fae82114ef4 --- /dev/null +++ b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_conditional_assign.php.inc @@ -0,0 +1,17 @@ +number = 100; + } else { + $this->number = 200; + } + } +} diff --git a/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_new_object.php.inc b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_new_object.php.inc new file mode 100644 index 00000000000..a9db071834a --- /dev/null +++ b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_new_object.php.inc @@ -0,0 +1,13 @@ +value = new \stdClass(); + } +} diff --git a/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_not_readonly.php.inc b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_not_readonly.php.inc new file mode 100644 index 00000000000..de5c186c6fe --- /dev/null +++ b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_not_readonly.php.inc @@ -0,0 +1,13 @@ +number = 100; + } +} diff --git a/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_variable_assign.php.inc b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_variable_assign.php.inc new file mode 100644 index 00000000000..63ec63fdc72 --- /dev/null +++ b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/Fixture/skip_variable_assign.php.inc @@ -0,0 +1,13 @@ +number = $number; + } +} diff --git a/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/config/configured_rule.php b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/config/configured_rule.php new file mode 100644 index 00000000000..efa2aa199aa --- /dev/null +++ b/rules-tests/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector/config/configured_rule.php @@ -0,0 +1,13 @@ +rule(ConstructorReadonlyAssignToDefaultRector::class); + + $rectorConfig->phpVersion(PhpVersion::PHP_86); +}; diff --git a/rules/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector.php b/rules/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector.php new file mode 100644 index 00000000000..e49c76191c8 --- /dev/null +++ b/rules/Php86/Rector/Class_/ConstructorReadonlyAssignToDefaultRector.php @@ -0,0 +1,200 @@ +number = 100; + } +} +CODE_SAMPLE + , + <<<'CODE_SAMPLE' +final class SomeClass +{ + public readonly int $number = 100; + + public function __construct() + { + } +} +CODE_SAMPLE + ), + ] + ); + } + + public function getNodeTypes(): array + { + return [Class_::class]; + } + + /** + * @param Class_ $node + */ + public function refactor(Node $node): ?Node + { + $constructClassMethod = $node->getMethod(MethodName::CONSTRUCT); + if (! $constructClassMethod instanceof ClassMethod) { + return null; + } + + if ($constructClassMethod->stmts === null) { + return null; + } + + $keysToRemove = []; + + foreach ($node->getProperties() as $property) { + if (! $this->isDefaultableReadonlyProperty($property)) { + continue; + } + + $propertyName = $this->getName($property->props[0]); + + $assignKey = $this->matchConstantAssignKey($constructClassMethod->stmts, $propertyName); + if ($assignKey === null) { + continue; + } + + $expression = $constructClassMethod->stmts[$assignKey]; + if (! $expression instanceof Expression || ! $expression->expr instanceof Assign) { + continue; + } + + $property->props[0]->default = $expression->expr->expr; + $keysToRemove[] = $assignKey; + } + + if ($keysToRemove === []) { + return null; + } + + foreach ($keysToRemove as $keyToRemove) { + unset($constructClassMethod->stmts[$keyToRemove]); + } + + $constructClassMethod->stmts = array_values($constructClassMethod->stmts); + + return $node; + } + + public function provideMinPhpVersion(): int + { + return PhpVersionFeature::READONLY_PROPERTY_DEFAULT_VALUE; + } + + private function isDefaultableReadonlyProperty(Property $property): bool + { + if (! $property->isReadonly()) { + return false; + } + + if ($property->isStatic()) { + return false; + } + + if ($property->hooks !== []) { + return false; + } + + if (count($property->props) !== 1) { + return false; + } + + return ! $property->props[0]->default instanceof Expr; + } + + /** + * @param Node\Stmt[] $stmts + */ + private function matchConstantAssignKey(array $stmts, string $propertyName): ?int + { + foreach ($stmts as $key => $stmt) { + if (! $stmt instanceof Expression) { + continue; + } + + if (! $stmt->expr instanceof Assign) { + continue; + } + + $assign = $stmt->expr; + if (! $assign->var instanceof PropertyFetch) { + continue; + } + + if (! $this->isName($assign->var->var, 'this')) { + continue; + } + + if (! $this->isName($assign->var->name, $propertyName)) { + continue; + } + + if (! $this->isConstantValue($assign->expr)) { + return null; + } + + return $key; + } + + return null; + } + + private function isConstantValue(Expr $expr): bool + { + if ($expr instanceof Scalar) { + return true; + } + + if ($expr instanceof ConstFetch || $expr instanceof ClassConstFetch) { + return true; + } + + if ($expr instanceof UnaryMinus || $expr instanceof UnaryPlus) { + return $this->isConstantValue($expr->expr); + } + + return false; + } +} diff --git a/src/ValueObject/PhpVersionFeature.php b/src/ValueObject/PhpVersionFeature.php index bcc06cd7c37..c4108883ad6 100644 --- a/src/ValueObject/PhpVersionFeature.php +++ b/src/ValueObject/PhpVersionFeature.php @@ -506,6 +506,11 @@ final class PhpVersionFeature */ public const int CLAMP = PhpVersion::PHP_86; + /** + * @see https://wiki.php.net/rfc/readonly_property_defaults + */ + public const int READONLY_PROPERTY_DEFAULT_VALUE = PhpVersion::PHP_86; + /** * @see https://php.watch/versions/8.0/named-parameters */