From 8a1cc74ddd67d31fb307c3d9cb26adf39ab0894e Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 6 May 2022 21:36:56 +0200 Subject: [PATCH 1/5] Implement readonly modifier for classes See https://wiki.php.net/rfc/readonly_classes --- composer.json | 2 +- src/main/php/lang/ast/emit/PHP70.class.php | 1 + src/main/php/lang/ast/emit/PHP71.class.php | 1 + src/main/php/lang/ast/emit/PHP72.class.php | 1 + src/main/php/lang/ast/emit/PHP74.class.php | 1 + src/main/php/lang/ast/emit/PHP80.class.php | 3 ++- src/main/php/lang/ast/emit/PHP81.class.php | 2 +- src/main/php/lang/ast/emit/PHP82.class.php | 2 +- .../lang/ast/emit/ReadonlyClasses.class.php | 21 +++++++++++++++++++ ...sTest.class.php => ReadonlyTest.class.php} | 19 ++++++++++++++--- 10 files changed, 46 insertions(+), 7 deletions(-) create mode 100755 src/main/php/lang/ast/emit/ReadonlyClasses.class.php rename src/test/php/lang/ast/unittest/emit/{ReadonlyPropertiesTest.class.php => ReadonlyTest.class.php} (90%) diff --git a/composer.json b/composer.json index 429acfec..65632929 100755 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "keywords": ["module", "xp"], "require" : { "xp-framework/core": "^11.0 | ^10.0", - "xp-framework/ast": "^8.0", + "xp-framework/ast": "dev-feature/readonly_classes as 8.1.0", "php" : ">=7.0.0" }, "require-dev" : { diff --git a/src/main/php/lang/ast/emit/PHP70.class.php b/src/main/php/lang/ast/emit/PHP70.class.php index 6962bb4b..5d7be79e 100755 --- a/src/main/php/lang/ast/emit/PHP70.class.php +++ b/src/main/php/lang/ast/emit/PHP70.class.php @@ -19,6 +19,7 @@ class PHP70 extends PHP { OmitArgumentNames, OmitConstModifiers, OmitPropertyTypes, + ReadonlyClasses, ReadonlyProperties, RewriteClassOnObjects, RewriteEnums, diff --git a/src/main/php/lang/ast/emit/PHP71.class.php b/src/main/php/lang/ast/emit/PHP71.class.php index 4aabf251..8ac6ba0f 100755 --- a/src/main/php/lang/ast/emit/PHP71.class.php +++ b/src/main/php/lang/ast/emit/PHP71.class.php @@ -19,6 +19,7 @@ class PHP71 extends PHP { OmitArgumentNames, OmitPropertyTypes, ReadonlyProperties, + ReadonlyClasses, RewriteClassOnObjects, RewriteEnums, RewriteExplicitOctals, diff --git a/src/main/php/lang/ast/emit/PHP72.class.php b/src/main/php/lang/ast/emit/PHP72.class.php index 2c9aa978..23a0bdc6 100755 --- a/src/main/php/lang/ast/emit/PHP72.class.php +++ b/src/main/php/lang/ast/emit/PHP72.class.php @@ -19,6 +19,7 @@ class PHP72 extends PHP { OmitArgumentNames, OmitPropertyTypes, ReadonlyProperties, + ReadonlyClasses, RewriteClassOnObjects, RewriteEnums, RewriteExplicitOctals, diff --git a/src/main/php/lang/ast/emit/PHP74.class.php b/src/main/php/lang/ast/emit/PHP74.class.php index bf7c1e87..25e9a286 100755 --- a/src/main/php/lang/ast/emit/PHP74.class.php +++ b/src/main/php/lang/ast/emit/PHP74.class.php @@ -17,6 +17,7 @@ class PHP74 extends PHP { NonCapturingCatchVariables, NullsafeAsTernaries, OmitArgumentNames, + ReadonlyClasses, ReadonlyProperties, RewriteBlockLambdaExpressions, RewriteClassOnObjects, diff --git a/src/main/php/lang/ast/emit/PHP80.class.php b/src/main/php/lang/ast/emit/PHP80.class.php index 779c224c..af4026a6 100755 --- a/src/main/php/lang/ast/emit/PHP80.class.php +++ b/src/main/php/lang/ast/emit/PHP80.class.php @@ -9,7 +9,8 @@ * @see https://wiki.php.net/rfc#php_80 */ class PHP80 extends PHP { - use RewriteBlockLambdaExpressions, RewriteExplicitOctals, RewriteEnums, ReadonlyProperties, CallablesAsClosures, ArrayUnpackUsingMerge; + use RewriteBlockLambdaExpressions, RewriteExplicitOctals, RewriteEnums; + use ReadonlyClasses, ReadonlyProperties, CallablesAsClosures, ArrayUnpackUsingMerge; /** Sets up type => literal mappings */ public function __construct() { diff --git a/src/main/php/lang/ast/emit/PHP81.class.php b/src/main/php/lang/ast/emit/PHP81.class.php index 1317fc28..4cc19ab2 100755 --- a/src/main/php/lang/ast/emit/PHP81.class.php +++ b/src/main/php/lang/ast/emit/PHP81.class.php @@ -10,7 +10,7 @@ * @see https://wiki.php.net/rfc#php_81 */ class PHP81 extends PHP { - use RewriteBlockLambdaExpressions; + use RewriteBlockLambdaExpressions, ReadonlyClasses; /** Sets up type => literal mappings */ public function __construct() { diff --git a/src/main/php/lang/ast/emit/PHP82.class.php b/src/main/php/lang/ast/emit/PHP82.class.php index df654511..78dc8370 100755 --- a/src/main/php/lang/ast/emit/PHP82.class.php +++ b/src/main/php/lang/ast/emit/PHP82.class.php @@ -10,7 +10,7 @@ * @see https://wiki.php.net/rfc#php_82 */ class PHP82 extends PHP { - use RewriteBlockLambdaExpressions; + use RewriteBlockLambdaExpressions, ReadonlyClasses; /** Sets up type => literal mappings */ public function __construct() { diff --git a/src/main/php/lang/ast/emit/ReadonlyClasses.class.php b/src/main/php/lang/ast/emit/ReadonlyClasses.class.php new file mode 100755 index 00000000..832f43e7 --- /dev/null +++ b/src/main/php/lang/ast/emit/ReadonlyClasses.class.php @@ -0,0 +1,21 @@ +modifiers))) { + unset($class->modifiers[$p]); + foreach ($class->body as $member) { + if ($member->is('property')) $member->modifiers[]= 'readonly'; + } + } + + return parent::emitClass($result, $class); + } +} \ No newline at end of file diff --git a/src/test/php/lang/ast/unittest/emit/ReadonlyPropertiesTest.class.php b/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php similarity index 90% rename from src/test/php/lang/ast/unittest/emit/ReadonlyPropertiesTest.class.php rename to src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php index 2f65771c..34fe9dbb 100755 --- a/src/test/php/lang/ast/unittest/emit/ReadonlyPropertiesTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php @@ -4,11 +4,12 @@ use unittest\{Assert, Expect, Test}; /** - * Readonly properties + * Readonly classes and properties * * @see https://wiki.php.net/rfc/readonly_properties_v2 + * @see https://wiki.php.net/rfc/readonly_classes */ -class ReadonlyPropertiesTest extends EmittingTest { +class ReadonlyTest extends EmittingTest { /** @return iterable */ private function modifiers() { @@ -20,7 +21,19 @@ private function modifiers() { } #[Test] - public function declaration() { + public function class_declaration() { + $t= $this->type('readonly class { + public int $fixture; + }'); + + Assert::equals( + sprintf('public readonly int %s::$fixture', $t->getName()), + $t->getField('fixture')->toString() + ); + } + + #[Test] + public function property_declaration() { $t= $this->type('class { public readonly int $fixture; }'); From a57bc102e805acb2999f05a5f0b802c2c2a84e21 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 6 May 2022 21:47:59 +0200 Subject: [PATCH 2/5] Inherit readonly on class to promoted constructor parameters --- .../lang/ast/emit/ReadonlyClasses.class.php | 11 +++++++++-- .../ast/unittest/emit/ReadonlyTest.class.php | 19 ++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/main/php/lang/ast/emit/ReadonlyClasses.class.php b/src/main/php/lang/ast/emit/ReadonlyClasses.class.php index 832f43e7..65a96da5 100755 --- a/src/main/php/lang/ast/emit/ReadonlyClasses.class.php +++ b/src/main/php/lang/ast/emit/ReadonlyClasses.class.php @@ -2,7 +2,8 @@ /** * Implements readonly properties by removing the `readonly` modifier from - * the class and inheriting it to all properties. + * the class and inheriting it to all properties (and promoted constructor + * arguments). * * @see https://wiki.php.net/rfc/readonly_classes */ @@ -12,7 +13,13 @@ protected function emitClass($result, $class) { if (false !== ($p= array_search('readonly', $class->modifiers))) { unset($class->modifiers[$p]); foreach ($class->body as $member) { - if ($member->is('property')) $member->modifiers[]= 'readonly'; + if ($member->is('property')) { + $member->modifiers[]= 'readonly'; + } else if ($member->is('method')) { + foreach ($member->signature->parameters as $param) { + $param->promote && $param->promote.= ' readonly'; + } + } } } diff --git a/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php b/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php index 34fe9dbb..72b6a101 100755 --- a/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php @@ -45,11 +45,28 @@ public function property_declaration() { } #[Test] - public function with_constructor_argument_promotion() { + public function class_with_constructor_argument_promotion() { + $t= $this->type('readonly class { + public function __construct(public string $fixture) { } + }'); + + Assert::equals( + sprintf('public readonly string %s::$fixture', $t->getName()), + $t->getField('fixture')->toString() + ); + Assert::equals('Test', $t->newInstance('Test')->fixture); + } + + #[Test] + public function property_defined_with_constructor_argument_promotion() { $t= $this->type('class { public function __construct(public readonly string $fixture) { } }'); + Assert::equals( + sprintf('public readonly string %s::$fixture', $t->getName()), + $t->getField('fixture')->toString() + ); Assert::equals('Test', $t->newInstance('Test')->fixture); } From 59691aee226771ae3229c13b7a85b46d7d1f9e6f Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 6 May 2022 22:21:47 +0200 Subject: [PATCH 3/5] Prevent dynamic members on readonly classes --- src/main/php/lang/ast/emit/PHP.class.php | 14 +++++++++----- .../php/lang/ast/emit/ReadonlyClasses.class.php | 8 ++++++++ .../lang/ast/unittest/emit/ReadonlyTest.class.php | 12 ++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/main/php/lang/ast/emit/PHP.class.php b/src/main/php/lang/ast/emit/PHP.class.php index d7d7728f..2544977a 100755 --- a/src/main/php/lang/ast/emit/PHP.class.php +++ b/src/main/php/lang/ast/emit/PHP.class.php @@ -383,7 +383,7 @@ protected function emitEnum($result, $enum) { protected function emitClass($result, $class) { array_unshift($result->type, $class); array_unshift($result->meta, []); - $result->locals= [[], [], []]; + $result->locals ?: $result->locals= [[], [], []]; $class->comment && $this->emitOne($result, $class->comment); $class->annotations && $this->emitOne($result, $class->annotations); @@ -399,21 +399,24 @@ protected function emitClass($result, $class) { if ($result->locals[2]) { $result->out->write('private $__virtual= ['); foreach ($result->locals[2] as $name => $access) { - $result->out->write("'{$name}' => null,"); + $name && $result->out->write("'{$name}' => null,"); } $result->out->write('];'); $result->out->write('public function __get($name) { switch ($name) {'); foreach ($result->locals[2] as $name => $access) { - $result->out->write('case "'.$name.'":'); + $result->out->write($name ? 'case "'.$name.'":' : 'default:'); $this->emitOne($result, $access[0]); $result->out->write('break;'); } - $result->out->write('default: trigger_error("Undefined property ".__CLASS__."::".$name, E_USER_WARNING); }}'); + isset($result->locals[2][null]) || $result->out->write( + 'default: trigger_error("Undefined property ".__CLASS__."::".$name, E_USER_WARNING);' + ); + $result->out->write('}}'); $result->out->write('public function __set($name, $value) { switch ($name) {'); foreach ($result->locals[2] as $name => $access) { - $result->out->write('case "'.$name.'":'); + $result->out->write($name ? 'case "'.$name.'":' : 'default:'); $this->emitOne($result, $access[1]); $result->out->write('break;'); } @@ -433,6 +436,7 @@ protected function emitClass($result, $class) { $this->emitMeta($result, $class->name, $class->annotations, $class->comment); $result->out->write('}} '.$class->name.'::__init();'); array_shift($result->type); + $result->locals= []; } protected function emitMeta($result, $name, $annotations, $comment) { diff --git a/src/main/php/lang/ast/emit/ReadonlyClasses.class.php b/src/main/php/lang/ast/emit/ReadonlyClasses.class.php index 65a96da5..ab29f97f 100755 --- a/src/main/php/lang/ast/emit/ReadonlyClasses.class.php +++ b/src/main/php/lang/ast/emit/ReadonlyClasses.class.php @@ -1,5 +1,7 @@ modifiers))) { unset($class->modifiers[$p]); + + // Inherit foreach ($class->body as $member) { if ($member->is('property')) { $member->modifiers[]= 'readonly'; @@ -21,6 +25,10 @@ protected function emitClass($result, $class) { } } } + + // Prevent dynamic members + $throw= new Code('throw new \\Error("Cannot create dynamic property ".__CLASS__."::".$name);'); + $result->locals= [[], [], [null => [$throw, $throw]]]; } return parent::emitClass($result, $class); diff --git a/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php b/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php index 72b6a101..bb86f0ae 100755 --- a/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php @@ -173,4 +173,16 @@ public function cannot_have_an_initial_value() { public readonly string $fixture= "Test"; }'); } + + #[Test, Expect(class: Error::class, withMessage: '/Cannot create dynamic property .+fixture/')] + public function cannot_read_dynamic_members_from_readonly_classes() { + $t= $this->type('readonly class { }'); + $t->newInstance()->fixture; + } + + #[Test, Expect(class: Error::class, withMessage: '/Cannot create dynamic property .+fixture/')] + public function cannot_write_dynamic_members_from_readonly_classes() { + $t= $this->type('readonly class { }'); + $t->newInstance()->fixture= true; + } } \ No newline at end of file From 72dfff60a1982a4390db63ee62cd56e8236254dc Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 6 May 2022 22:26:07 +0200 Subject: [PATCH 4/5] Add test for static properties --- src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php b/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php index bb86f0ae..f13ae5a5 100755 --- a/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php @@ -185,4 +185,11 @@ public function cannot_write_dynamic_members_from_readonly_classes() { $t= $this->type('readonly class { }'); $t->newInstance()->fixture= true; } + + #[Test, Ignore('Until proper error handling facilities exist')] + public function readonly_classes_cannot_have_static_members() { + $this->type('readonly class { + public static $test; + }'); + } } \ No newline at end of file From 06fa65bdb89a1fd4b252e7115c253205d000852c Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 14 May 2022 17:03:42 +0200 Subject: [PATCH 5/5] Upgrade to release version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 65632929..d36e9284 100755 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "keywords": ["module", "xp"], "require" : { "xp-framework/core": "^11.0 | ^10.0", - "xp-framework/ast": "dev-feature/readonly_classes as 8.1.0", + "xp-framework/ast": "^8.1", "php" : ">=7.0.0" }, "require-dev" : {