Skip to content

Commit 2a5e822

Browse files
[Php83] Add ReadOnlyAnonymousClassRector (#6916)
* [Php83] Add ReadOnlyAnonymousClassRector * Fix phpstan * Fix phpstan * [ci-review] Rector Rectify * Fix eol * more fixture --------- Co-authored-by: GitHub Action <actions@github.com>
1 parent b29132b commit 2a5e822

File tree

14 files changed

+479
-236
lines changed

14 files changed

+479
-236
lines changed

config/set/php83.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
declare(strict_types=1);
44

55
use Rector\Config\RectorConfig;
6+
use Rector\Php83\Rector\Class_\ReadOnlyAnonymousClassRector;
67
use Rector\Php83\Rector\ClassConst\AddTypeToConstRector;
78
use Rector\Php83\Rector\ClassMethod\AddOverrideAttributeToOverriddenMethodsRector;
89
use Rector\Php83\Rector\FuncCall\CombineHostPortLdapUriRector;
@@ -14,5 +15,6 @@
1415
AddTypeToConstRector::class,
1516
CombineHostPortLdapUriRector::class,
1617
RemoveGetClassGetParentClassNoArgsRector::class,
18+
ReadOnlyAnonymousClassRector::class,
1719
]);
1820
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace newRector\Tests\Php83\Rector\Class_\ReadOnlyAnonymousClassRector\Fixture;
4+
5+
use Rector\Tests\Php83\Rector\Class_\ReadOnlyAnonymousClassRector\Source\ParentAlreadyReadonly;
6+
7+
new class() extends ParentAlreadyReadonly
8+
{
9+
private readonly string $name = 'test';
10+
};
11+
12+
?>
13+
-----
14+
<?php
15+
16+
namespace newRector\Tests\Php83\Rector\Class_\ReadOnlyAnonymousClassRector\Fixture;
17+
18+
use Rector\Tests\Php83\Rector\Class_\ReadOnlyAnonymousClassRector\Source\ParentAlreadyReadonly;
19+
20+
new readonly class() extends ParentAlreadyReadonly
21+
{
22+
private string $name = 'test';
23+
};
24+
25+
?>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace newRector\Tests\Php83\Rector\Class_\ReadOnlyAnonymousClassRector\Fixture;
4+
5+
(new class()
6+
{
7+
private readonly string $name = 'test';
8+
});
9+
10+
?>
11+
-----
12+
<?php
13+
14+
namespace newRector\Tests\Php83\Rector\Class_\ReadOnlyAnonymousClassRector\Fixture;
15+
16+
(new readonly class()
17+
{
18+
private string $name = 'test';
19+
});
20+
21+
?>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace newRector\Tests\Php83\Rector\Class_\ReadOnlyAnonymousClassRector\Fixture;
4+
5+
class SkipNamedClass
6+
{
7+
private readonly string $name = 'test';
8+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace newRector\Tests\Php83\Rector\Class_\ReadOnlyAnonymousClassRector\Fixture;
4+
5+
new class()
6+
{
7+
private readonly string $name = 'test';
8+
};
9+
10+
?>
11+
-----
12+
<?php
13+
14+
namespace newRector\Tests\Php83\Rector\Class_\ReadOnlyAnonymousClassRector\Fixture;
15+
16+
new readonly class()
17+
{
18+
private string $name = 'test';
19+
};
20+
21+
?>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\Tests\Php83\Rector\Class_\ReadOnlyAnonymousClassRector;
6+
7+
use Iterator;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use Rector\Testing\PHPUnit\AbstractRectorTestCase;
10+
11+
final class ReadOnlyAnonymousClassRectorTest extends AbstractRectorTestCase
12+
{
13+
#[DataProvider('provideData')]
14+
public function test(string $filePath): void
15+
{
16+
$this->doTestFile($filePath);
17+
}
18+
19+
public static function provideData(): Iterator
20+
{
21+
return self::yieldFilesFromDirectory(__DIR__ . '/Fixture');
22+
}
23+
24+
public function provideConfigFilePath(): string
25+
{
26+
return __DIR__ . '/config/configured_rule.php';
27+
}
28+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\Tests\Php83\Rector\Class_\ReadOnlyAnonymousClassRector\Source;
6+
7+
readonly abstract class ParentAlreadyReadonly
8+
{
9+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Rector\Config\RectorConfig;
6+
use Rector\Php83\Rector\Class_\ReadOnlyAnonymousClassRector;
7+
use Rector\ValueObject\PhpVersionFeature;
8+
9+
return static function (RectorConfig $rectorConfig): void {
10+
$rectorConfig->rule(ReadOnlyAnonymousClassRector::class);
11+
12+
$rectorConfig->phpVersion(PhpVersionFeature::READONLY_ANONYMOUS_CLASS);
13+
};
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\Php82\NodeManipulator;
6+
7+
use PhpParser\Node\Name\FullyQualified;
8+
use PhpParser\Node\Param;
9+
use PhpParser\Node\Stmt\Class_;
10+
use PhpParser\Node\Stmt\ClassMethod;
11+
use PhpParser\Node\Stmt\Property;
12+
use PHPStan\Analyser\Scope;
13+
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionProperty;
14+
use PHPStan\Reflection\ClassReflection;
15+
use PHPStan\Reflection\ReflectionProvider;
16+
use Rector\Php80\NodeAnalyzer\PhpAttributeAnalyzer;
17+
use Rector\Php81\Enum\AttributeName;
18+
use Rector\Php81\NodeManipulator\AttributeGroupNewLiner;
19+
use Rector\PHPStan\ScopeFetcher;
20+
use Rector\Privatization\NodeManipulator\VisibilityManipulator;
21+
use Rector\ValueObject\Application\File;
22+
use Rector\ValueObject\MethodName;
23+
use Rector\ValueObject\Visibility;
24+
25+
final readonly class ReadonlyClassManipulator
26+
{
27+
public function __construct(
28+
private VisibilityManipulator $visibilityManipulator,
29+
private PhpAttributeAnalyzer $phpAttributeAnalyzer,
30+
private ReflectionProvider $reflectionProvider,
31+
private AttributeGroupNewLiner $attributeGroupNewLiner
32+
) {
33+
}
34+
35+
public function process(Class_ $class, File $file): Class_|null
36+
{
37+
$scope = ScopeFetcher::fetch($class);
38+
if ($this->shouldSkip($class, $scope)) {
39+
return null;
40+
}
41+
42+
$this->visibilityManipulator->makeReadonly($class);
43+
44+
$constructClassMethod = $class->getMethod(MethodName::CONSTRUCT);
45+
46+
if ($constructClassMethod instanceof ClassMethod) {
47+
foreach ($constructClassMethod->getParams() as $param) {
48+
$this->visibilityManipulator->removeReadonly($param);
49+
50+
if ($param->attrGroups !== []) {
51+
$this->attributeGroupNewLiner->newLine($file, $param);
52+
}
53+
}
54+
}
55+
56+
foreach ($class->getProperties() as $property) {
57+
$this->visibilityManipulator->removeReadonly($property);
58+
59+
if ($property->attrGroups !== []) {
60+
$this->attributeGroupNewLiner->newLine($file, $property);
61+
}
62+
}
63+
64+
if ($class->attrGroups !== []) {
65+
$this->attributeGroupNewLiner->newLine($file, $class);
66+
}
67+
68+
return $class;
69+
}
70+
71+
/**
72+
* @return ClassReflection[]
73+
*/
74+
private function resolveParentClassReflections(Scope $scope): array
75+
{
76+
$classReflection = $scope->getClassReflection();
77+
if (! $classReflection instanceof ClassReflection) {
78+
return [];
79+
}
80+
81+
return $classReflection->getParents();
82+
}
83+
84+
/**
85+
* @param Property[] $properties
86+
*/
87+
private function hasNonTypedProperty(array $properties): bool
88+
{
89+
foreach ($properties as $property) {
90+
// properties of readonly class must always have type
91+
if ($property->type === null) {
92+
return true;
93+
}
94+
}
95+
96+
return false;
97+
}
98+
99+
private function shouldSkip(Class_ $class, Scope $scope): bool
100+
{
101+
$classReflection = $scope->getClassReflection();
102+
if (! $classReflection instanceof ClassReflection) {
103+
return true;
104+
}
105+
106+
if ($this->shouldSkipClass($class)) {
107+
return true;
108+
}
109+
110+
$parents = $this->resolveParentClassReflections($scope);
111+
112+
if (! $class->isAnonymous() && ! $class->isFinal()) {
113+
return ! $this->isExtendsReadonlyClass($parents);
114+
}
115+
116+
foreach ($parents as $parent) {
117+
if (! $parent->isReadOnly()) {
118+
return true;
119+
}
120+
}
121+
122+
$properties = $class->getProperties();
123+
if ($this->hasWritableProperty($properties)) {
124+
return true;
125+
}
126+
127+
if ($this->hasNonTypedProperty($properties)) {
128+
return true;
129+
}
130+
131+
if ($this->shouldSkipConsumeTraitProperty($class)) {
132+
return true;
133+
}
134+
135+
$constructClassMethod = $class->getMethod(MethodName::CONSTRUCT);
136+
if (! $constructClassMethod instanceof ClassMethod) {
137+
// no __construct means no property promotion, skip if class has no property defined
138+
return $properties === [];
139+
}
140+
141+
$params = $constructClassMethod->getParams();
142+
if ($params === []) {
143+
// no params means no property promotion, skip if class has no property defined
144+
return $properties === [];
145+
}
146+
147+
return $this->shouldSkipParams($params);
148+
}
149+
150+
private function shouldSkipConsumeTraitProperty(Class_ $class): bool
151+
{
152+
$traitUses = $class->getTraitUses();
153+
foreach ($traitUses as $traitUse) {
154+
foreach ($traitUse->traits as $trait) {
155+
$traitName = $trait->toString();
156+
157+
// trait not autoloaded
158+
if (! $this->reflectionProvider->hasClass($traitName)) {
159+
return true;
160+
}
161+
162+
$traitClassReflection = $this->reflectionProvider->getClass($traitName);
163+
$nativeReflection = $traitClassReflection->getNativeReflection();
164+
165+
if ($this->hasReadonlyProperty($nativeReflection->getProperties())) {
166+
return true;
167+
}
168+
}
169+
}
170+
171+
return false;
172+
}
173+
174+
/**
175+
* @param ReflectionProperty[] $properties
176+
*/
177+
private function hasReadonlyProperty(array $properties): bool
178+
{
179+
foreach ($properties as $property) {
180+
if (! $property->isReadOnly()) {
181+
return true;
182+
}
183+
}
184+
185+
return false;
186+
}
187+
188+
/**
189+
* @param ClassReflection[] $parents
190+
*/
191+
private function isExtendsReadonlyClass(array $parents): bool
192+
{
193+
foreach ($parents as $parent) {
194+
if ($parent->isReadOnly()) {
195+
return true;
196+
}
197+
}
198+
199+
return false;
200+
}
201+
202+
/**
203+
* @param Property[] $properties
204+
*/
205+
private function hasWritableProperty(array $properties): bool
206+
{
207+
foreach ($properties as $property) {
208+
if (! $property->isReadonly()) {
209+
return true;
210+
}
211+
}
212+
213+
return false;
214+
}
215+
216+
private function shouldSkipClass(Class_ $class): bool
217+
{
218+
// need to have test fixture once feature added to nikic/PHP-Parser
219+
if ($this->visibilityManipulator->hasVisibility($class, Visibility::READONLY)) {
220+
return true;
221+
}
222+
223+
if ($this->phpAttributeAnalyzer->hasPhpAttribute($class, AttributeName::ALLOW_DYNAMIC_PROPERTIES)) {
224+
return true;
225+
}
226+
227+
return $class->extends instanceof FullyQualified && ! $this->reflectionProvider->hasClass(
228+
$class->extends->toString()
229+
);
230+
}
231+
232+
/**
233+
* @param Param[] $params
234+
*/
235+
private function shouldSkipParams(array $params): bool
236+
{
237+
foreach ($params as $param) {
238+
// has non-readonly property promotion
239+
if (! $this->visibilityManipulator->hasVisibility($param, Visibility::READONLY) && $param->isPromoted()) {
240+
return true;
241+
}
242+
243+
// type is missing, invalid syntax
244+
if ($param->type === null) {
245+
return true;
246+
}
247+
}
248+
249+
return false;
250+
}
251+
}

0 commit comments

Comments
 (0)