Skip to content

Commit 921e7fe

Browse files
committed
ReadonlyClassSniff for check to readonly class / promoted properties
1 parent d3e1e43 commit 921e7fe

8 files changed

Lines changed: 354 additions & 0 deletions
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SlevomatCodingStandard\Sniffs\Classes;
6+
7+
use PHP_CodeSniffer\Files\File;
8+
use PHP_CodeSniffer\Sniffs\Sniff;
9+
use SlevomatCodingStandard\Helpers\ClassHelper;
10+
use SlevomatCodingStandard\Helpers\FunctionHelper;
11+
use SlevomatCodingStandard\Helpers\SniffSettingsHelper;
12+
use SlevomatCodingStandard\Helpers\TokenHelper;
13+
14+
use function count;
15+
use function in_array;
16+
use function sprintf;
17+
use function strtolower;
18+
19+
use const T_ABSTRACT;
20+
use const T_ATTRIBUTE_END;
21+
use const T_CLASS;
22+
use const T_COMMA;
23+
use const T_FINAL;
24+
use const T_FUNCTION;
25+
use const T_OPEN_PARENTHESIS;
26+
use const T_READONLY;
27+
use const T_VARIABLE;
28+
use const T_WHITESPACE;
29+
30+
class ReadonlyClassSniff implements Sniff
31+
{
32+
public const CODE_CLASS_CAN_BE_READONLY = 'ClassCanBeReadonly';
33+
public const CODE_PROMOTED_PROPERTY_CANNOT_BE_READONLY_IN_READONLY_CLASS = 'PromotedPropertyCannotBeReadonlyInReadonlyClass';
34+
public ?bool $enable = null;
35+
/**
36+
* @return array<int, (int|string)>
37+
*/
38+
public function register(): array
39+
{
40+
return [T_CLASS];
41+
}
42+
43+
public function process(File $phpcsFile, int $classPointer): void
44+
{
45+
$this->enable = SniffSettingsHelper::isEnabledByPhpVersion($this->enable, 80200);
46+
if (!$this->enable) {
47+
return;
48+
}
49+
50+
$constructorPointer = $this->findConstructorPointer($phpcsFile, $classPointer);
51+
if ($constructorPointer === null) {
52+
return;
53+
}
54+
55+
$promotedProperties = $this->getPromotedProperties($phpcsFile, $constructorPointer);
56+
if (count($promotedProperties) === 0) {
57+
return;
58+
}
59+
60+
$tokens = $phpcsFile->getTokens();
61+
if ($this->isReadonlyClass($phpcsFile, $classPointer)) {
62+
foreach ($promotedProperties as $promotedProperty) {
63+
$readonlyModifierPointer = $promotedProperty['readonlyModifierPointer'];
64+
if ($readonlyModifierPointer === null) {
65+
continue;
66+
}
67+
68+
$fix = $phpcsFile->addFixableError(sprintf('Promoted property %s in readonly class cannot be declared as readonly.', $tokens[$promotedProperty['propertyPointer']]['content'],), $readonlyModifierPointer, self::CODE_PROMOTED_PROPERTY_CANNOT_BE_READONLY_IN_READONLY_CLASS,);
69+
if (!$fix) {
70+
continue;
71+
}
72+
73+
$phpcsFile->fixer->beginChangeset();
74+
$this->removeReadonlyModifier($phpcsFile, $readonlyModifierPointer);
75+
$phpcsFile->fixer->endChangeset();
76+
}
77+
78+
return;
79+
}
80+
81+
foreach ($promotedProperties as $promotedProperty) {
82+
if ($promotedProperty['readonlyModifierPointer'] === null) {
83+
return;
84+
}
85+
}
86+
87+
$fix = $phpcsFile->addFixableError(sprintf('Class %s can be marked as readonly.', ClassHelper::getName($phpcsFile, $classPointer)), $classPointer, self::CODE_CLASS_CAN_BE_READONLY,);
88+
if (!$fix) {
89+
return;
90+
}
91+
92+
$phpcsFile->fixer->beginChangeset();
93+
foreach ($promotedProperties as $promotedProperty) {
94+
$this->removeReadonlyModifier($phpcsFile, $promotedProperty['readonlyModifierPointer']);
95+
}
96+
97+
$phpcsFile->fixer->addContentBefore($classPointer, 'readonly ');
98+
$phpcsFile->fixer->endChangeset();
99+
}
100+
101+
private function findConstructorPointer(File $phpcsFile, int $classPointer): ?int
102+
{
103+
$tokens = $phpcsFile->getTokens();
104+
$classLevel = $tokens[$classPointer]['level'];
105+
106+
for ($i = $tokens[$classPointer]['scope_opener'] + 1; $i < $tokens[$classPointer]['scope_closer']; $i++) {
107+
if ($tokens[$i]['code'] !== T_FUNCTION) {
108+
continue;
109+
}
110+
111+
if ($tokens[$i]['level'] !== $classLevel + 1) {
112+
continue;
113+
}
114+
115+
if (strtolower(FunctionHelper::getName($phpcsFile, $i)) !== '__construct') {
116+
continue;
117+
}
118+
119+
return $i;
120+
}
121+
122+
return null;
123+
}
124+
125+
private function isReadonlyClass(File $phpcsFile, int $classPointer): bool
126+
{
127+
$tokens = $phpcsFile->getTokens();
128+
$modifierPointer = TokenHelper::findPreviousEffective($phpcsFile, $classPointer - 1);
129+
while (
130+
$modifierPointer !== null
131+
&& in_array($tokens[$modifierPointer]['code'], [T_FINAL, T_ABSTRACT, T_READONLY], true)
132+
) {
133+
if ($tokens[$modifierPointer]['code'] === T_READONLY) {
134+
return true;
135+
}
136+
137+
$modifierPointer = TokenHelper::findPreviousEffective($phpcsFile, $modifierPointer - 1);
138+
}
139+
140+
return false;
141+
}
142+
143+
/**
144+
* @return list<array{propertyPointer: int, readonlyModifierPointer: ?int}>
145+
*/
146+
private function getPromotedProperties(File $phpcsFile, int $constructorPointer): array
147+
{
148+
$tokens = $phpcsFile->getTokens();
149+
$promotedProperties = [];
150+
151+
for ($i = $tokens[$constructorPointer]['parenthesis_opener'] + 1; $i < $tokens[$constructorPointer]['parenthesis_closer']; $i++) {
152+
if ($tokens[$i]['code'] !== T_VARIABLE) {
153+
continue;
154+
}
155+
156+
$pointerBefore = TokenHelper::findPrevious($phpcsFile, [T_COMMA, T_OPEN_PARENTHESIS, T_ATTRIBUTE_END], $i - 1);
157+
$modifierPointer = TokenHelper::findNextEffective($phpcsFile, $pointerBefore + 1);
158+
if (!in_array($tokens[$modifierPointer]['code'], TokenHelper::PROPERTY_MODIFIERS_TOKEN_CODES, true)) {
159+
continue;
160+
}
161+
162+
$readonlyModifierPointer = TokenHelper::findNext($phpcsFile, T_READONLY, $modifierPointer, $i);
163+
$promotedProperties[] = [
164+
'propertyPointer' => $i,
165+
'readonlyModifierPointer' => $readonlyModifierPointer,
166+
];
167+
}
168+
169+
return $promotedProperties;
170+
}
171+
172+
private function removeReadonlyModifier(File $phpcsFile, int $readonlyModifierPointer): void
173+
{
174+
$phpcsFile->fixer->replaceToken($readonlyModifierPointer, '');
175+
$nextPointer = TokenHelper::findNext($phpcsFile, T_WHITESPACE, $readonlyModifierPointer + 1, $readonlyModifierPointer + 2);
176+
if ($nextPointer !== null) {
177+
$phpcsFile->fixer->replaceToken($nextPointer, '');
178+
}
179+
}
180+
}

doc/classes.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,12 @@ Sniff provides the following settings:
232232
* `minLinesCountBeforeMultiline` (default: `null`): minimum number of lines before multiline property
233233
* `maxLinesCountBeforeMultiline` (default: `null`): maximum number of lines before multiline property
234234

235+
#### SlevomatCodingStandard.Classes.ReadonlyClass 🔧
236+
237+
Reports classes where all promoted constructor properties are declared as `readonly` and suggests marking the whole class as `readonly`.
238+
239+
In readonly classes, promoted constructor properties must not be explicitly declared as `readonly`.
240+
235241
#### SlevomatCodingStandard.Classes.RequireAbstractOrFinal 🔧
236242

237243
Requires the class to be declared either as abstract or as final.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SlevomatCodingStandard\Sniffs\Classes;
6+
7+
use SlevomatCodingStandard\Sniffs\TestCase;
8+
9+
class ReadonlyClassSniffTest extends TestCase
10+
{
11+
public function testNoErrors(): void
12+
{
13+
$this->skipIfReadonlyClassIsNotSupported();
14+
$report = self::checkFile(__DIR__ . '/data/readonlyClassNoErrors.php');
15+
self::assertNoSniffErrorInFile($report);
16+
}
17+
18+
public function testErrors(): void
19+
{
20+
$this->skipIfReadonlyClassIsNotSupported();
21+
$report = self::checkFile(__DIR__ . '/data/readonlyClassErrors.php');
22+
self::assertSame(4, $report->getErrorCount());
23+
self::assertSniffError($report, 4, ReadonlyClassSniff::CODE_CLASS_CAN_BE_READONLY, 'Class Candidate can be marked as readonly.');
24+
self::assertSniffError($report, 17, ReadonlyClassSniff::CODE_PROMOTED_PROPERTY_CANNOT_BE_READONLY_IN_READONLY_CLASS, 'Promoted property $id in readonly class cannot be declared as readonly.');
25+
self::assertSniffError($report, 18, ReadonlyClassSniff::CODE_PROMOTED_PROPERTY_CANNOT_BE_READONLY_IN_READONLY_CLASS, 'Promoted property $name in readonly class cannot be declared as readonly.');
26+
self::assertSniffError($report, 24, ReadonlyClassSniff::CODE_CLASS_CAN_BE_READONLY, 'Class FinalCandidate can be marked as readonly.');
27+
self::assertAllFixedInFile($report);
28+
}
29+
30+
public function testNoConstructorNoErrors(): void
31+
{
32+
$this->skipIfReadonlyClassIsNotSupported();
33+
$report = self::checkFile(__DIR__ . '/data/readonlyClassNoConstructorNoErrors.php');
34+
self::assertNoSniffErrorInFile($report);
35+
}
36+
37+
public function testFindConstructorPointerBranchesNoErrors(): void
38+
{
39+
$this->skipIfReadonlyClassIsNotSupported();
40+
$report = self::checkFile(__DIR__ . '/data/readonlyClassFindConstructorPointerBranchesNoErrors.php');
41+
self::assertNoSniffErrorInFile($report);
42+
}
43+
44+
public function testShouldNotReportIfSniffIsDisabled(): void
45+
{
46+
$this->skipIfReadonlyClassIsNotSupported();
47+
$report = self::checkFile(__DIR__ . '/data/readonlyClassErrors.php', [
48+
'enable' => false,
49+
]);
50+
self::assertNoSniffErrorInFile($report);
51+
}
52+
53+
private function skipIfReadonlyClassIsNotSupported(): void
54+
{
55+
if (PHP_VERSION_ID < 80200) {
56+
self::markTestSkipped('Readonly classes are supported in PHP 8.2+ only.');
57+
}
58+
}
59+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
// lint >= 8.2
4+
readonly class Candidate
5+
{
6+
public function __construct(
7+
private int $id,
8+
public string $name,
9+
)
10+
{
11+
}
12+
}
13+
14+
readonly class InvalidReadonly
15+
{
16+
public function __construct(
17+
private int $id,
18+
protected string $name,
19+
)
20+
{
21+
}
22+
}
23+
24+
final readonly class FinalCandidate
25+
{
26+
public function __construct(private int $id)
27+
{
28+
}
29+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
// lint >= 8.2
4+
class Candidate
5+
{
6+
public function __construct(
7+
private readonly int $id,
8+
public readonly string $name,
9+
)
10+
{
11+
}
12+
}
13+
14+
readonly class InvalidReadonly
15+
{
16+
public function __construct(
17+
private readonly int $id,
18+
protected readonly string $name,
19+
)
20+
{
21+
}
22+
}
23+
24+
final class FinalCandidate
25+
{
26+
public function __construct(private readonly int $id)
27+
{
28+
}
29+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
// lint >= 8.2
4+
class ConstructorPointerBranches
5+
{
6+
public function process(): void
7+
{
8+
$anonymous = new class {
9+
public function __construct(private readonly int $id)
10+
{
11+
}
12+
};
13+
}
14+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
// lint >= 8.2
4+
class WithoutConstructor
5+
{
6+
private int $id;
7+
}
8+
9+
readonly class ReadonlyWithoutConstructor
10+
{
11+
private int $id;
12+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
// lint >= 8.2
4+
class MixedPromotion
5+
{
6+
public function __construct(private readonly int $id, private string $name,)
7+
{
8+
}
9+
}
10+
11+
readonly class ValidReadonly
12+
{
13+
public function __construct(private int $id, private string $name,)
14+
{
15+
}
16+
}
17+
18+
class WithoutPromotion
19+
{
20+
private readonly int $id;
21+
public function __construct(int $id)
22+
{
23+
$this->id = $id;
24+
}
25+
}

0 commit comments

Comments
 (0)