Skip to content

Commit 5cc4dc9

Browse files
committed
fix: replace visibility-less readonly with public instead of removing it (#72) [Closes #49]
"readonly T $x" without an explicit visibility modifier is valid PHP 8.4+ constructor promotion syntax. Stripping readonly entirely turned the promoted property into a plain constructor argument, making $this->x inaccessible. removeTokens() now decides each 'final'/'readonly' token with a match on the surrounding significant tokens: drop it, keep it, or - for a visibility-less promoted readonly - replace it with "public" so it stays a promoted property. Visibility detection covers PHP 8.4 asymmetric modifiers (private(set) etc.) and tolerates comments between tokens. Closes #49 Signed-off-by: Guillaume Delré <delre.guillaume@gmail.com>
1 parent 03e27ac commit 5cc4dc9

3 files changed

Lines changed: 98 additions & 16 deletions

File tree

src/BypassFinals.php

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,17 @@ public static function removeTokens(string $code): string
182182
foreach ($tokens as $i => $token) {
183183
if (!is_array($token)) {
184184
$code .= $token;
185-
} elseif (!isset(self::$tokens[$token[0]]) || !self::shouldRemoveToken($tokens, $i)) {
185+
} elseif ($token[0] === T_FINAL && isset(self::$tokens[T_FINAL])) {
186+
// drop 'final' before class/function/readonly, but keep e.g. "final const"
187+
$next = self::significantToken($tokens, $i, 1);
188+
$code .= is_array($next) && in_array($next[0], [T_CLASS, T_FUNCTION, T_READONLY], true) ? '' : $token[1];
189+
} elseif ($token[0] === T_READONLY && isset(self::$tokens[T_READONLY])) {
190+
// drop 'readonly' before a class or after a visibility modifier; a visibility-less
191+
// promoted "readonly T $x" (PHP 8.4+) becomes "public" to stay a promoted property
192+
$next = self::significantToken($tokens, $i, 1);
193+
$prev = self::significantToken($tokens, $i, -1);
194+
$code .= (is_array($next) && $next[0] === T_CLASS) || is_array($prev) ? '' : 'public';
195+
} else {
186196
$code .= $token[1];
187197
}
188198
}
@@ -192,26 +202,20 @@ public static function removeTokens(string $code): string
192202

193203

194204
/**
195-
* Determines if a token should be removed based on its context.
205+
* Returns the nearest token in the given direction (+1/-1) that is not whitespace or a comment.
206+
* @return string|array{int, string, int}|null
196207
*/
197-
private static function shouldRemoveToken(array $tokens, int $index): bool
208+
private static function significantToken(array $tokens, int $index, int $direction)
198209
{
199-
$tokenType = $tokens[$index][0];
200-
201-
if ($tokenType === T_FINAL) {
202-
for ($index++; $index < count($tokens); $index++) {
203-
$token = $tokens[$index];
204-
if (is_array($token) && $token[0] === T_WHITESPACE) {
205-
continue;
206-
}
207-
208-
return is_array($token) && in_array($token[0], [T_CLASS, T_FUNCTION, T_READONLY], true);
210+
for ($j = $index + $direction; isset($tokens[$j]); $j += $direction) {
211+
$t = $tokens[$j];
212+
if (is_array($t) && in_array($t[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) {
213+
continue;
209214
}
210-
} elseif ($tokenType === T_READONLY) {
211-
return true;
215+
return $t;
212216
}
213217

214-
return false;
218+
return null;
215219
}
216220

217221

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php declare(strict_types=1);
2+
3+
/** @phpVersion 8.4 */
4+
5+
// Removing readonly must not break PHP 8.4+ asymmetric visibility (private(set) etc.).
6+
7+
use Tester\Assert;
8+
9+
require __DIR__ . '/../bootstrap.php';
10+
11+
12+
DG\BypassFinals::enable(bypassReadOnly: true, bypassFinal: false);
13+
14+
$originalCode = <<<'XX'
15+
<?php declare(strict_types=1);
16+
17+
final class ReadonlyAsymmetric
18+
{
19+
public function __construct(
20+
public private(set) readonly string $a,
21+
) {
22+
}
23+
}
24+
XX;
25+
26+
$modifiedCode = DG\BypassFinals::removeTokens($originalCode);
27+
28+
Assert::match(<<<'XX'
29+
<?php declare(strict_types=1);
30+
31+
final class ReadonlyAsymmetric
32+
{
33+
public function __construct(
34+
public private(set) string $a,
35+
) {
36+
}
37+
}
38+
XX, $modifiedCode);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php declare(strict_types=1);
2+
3+
/** @phpVersion 8.4 */
4+
5+
// Visibility-less promoted "readonly $x" (PHP 8.4+) must become "public", not be dropped.
6+
7+
use Tester\Assert;
8+
9+
require __DIR__ . '/../bootstrap.php';
10+
11+
12+
DG\BypassFinals::enable(bypassReadOnly: true, bypassFinal: false);
13+
14+
$originalCode = <<<'XX'
15+
<?php declare(strict_types=1);
16+
17+
final class ReadonlyNoVisibility
18+
{
19+
public function __construct(
20+
readonly string $a,
21+
public readonly string $b,
22+
) {
23+
}
24+
}
25+
XX;
26+
27+
$modifiedCode = DG\BypassFinals::removeTokens($originalCode);
28+
29+
Assert::match(<<<'XX'
30+
<?php declare(strict_types=1);
31+
32+
final class ReadonlyNoVisibility
33+
{
34+
public function __construct(
35+
public string $a,
36+
public string $b,
37+
) {
38+
}
39+
}
40+
XX, $modifiedCode);

0 commit comments

Comments
 (0)