diff --git a/composer.json b/composer.json index 7f815af6..b06dd5cf 100755 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "require" : { "xp-framework/core": "^12.0 | ^11.6 | ^10.16", "xp-framework/reflection": "^3.2 | ^2.15", - "xp-framework/ast": "^11.1", + "xp-framework/ast": "^11.3", "php" : ">=7.4.0" }, "require-dev" : { diff --git a/src/main/php/lang/ast/emit/AsymmetricVisibility.class.php b/src/main/php/lang/ast/emit/AsymmetricVisibility.class.php new file mode 100755 index 00000000..9bf53ce0 --- /dev/null +++ b/src/main/php/lang/ast/emit/AsymmetricVisibility.class.php @@ -0,0 +1,81 @@ + MODIFIER_PUBLIC, + 'protected' => MODIFIER_PROTECTED, + 'private' => MODIFIER_PRIVATE, + 'static' => MODIFIER_STATIC, + 'final' => MODIFIER_FINAL, + 'abstract' => MODIFIER_ABSTRACT, + 'readonly' => MODIFIER_READONLY, + 'public(set)' => 0x1000000, + 'protected(set)' => 0x0000800, + 'private(set)' => 0x0001000, + ]; + + $scope= $result->codegen->scope[0]; + $modifiers= 0; + foreach ($property->modifiers as $name) { + $modifiers|= $lookup[$name]; + } + + // Declare checks for private(set) and protected(set), folding declarations + // like `[visibility] [visibility](set)` to just the visibility itself. + if ($modifiers & 0x1000000) { + $checks= []; + $modifiers&= ~0x1000000; + } else if ($modifiers & 0x0000800) { + $checks= [$this->protected($property->name, 'modify protected(set)')]; + $modifiers & MODIFIER_PROTECTED && $modifiers&= ~0x0000800; + } else if ($modifiers & 0x0001000) { + $checks= [$this->private($property->name, 'modify private(set)')]; + $modifiers & MODIFIER_PRIVATE && $modifiers&= ~0x0001000; + } + + // Emit XP meta information for the reflection API + $scope->meta[self::PROPERTY][$property->name]= [ + DETAIL_RETURNS => $property->type ? $property->type->name() : 'var', + DETAIL_ANNOTATIONS => $property->annotations, + DETAIL_COMMENT => $property->comment, + DETAIL_TARGET_ANNO => [], + DETAIL_ARGUMENTS => [$modifiers] + ]; + + // The readonly flag is really two flags in one: write-once and restricted(set) + if (in_array('readonly', $property->modifiers)) { + $checks[]= $this->initonce($property->name); + } + + $virtual= new InstanceExpression(new Variable('this'), new OffsetExpression( + new Literal('__virtual'), + new Literal("'{$property->name}'")) + ); + $scope->virtual[$property->name]= [ + new ReturnStatement($virtual), + new Block([...$checks, new Assignment($virtual, '=', new Variable('value'))]), + ]; + if (isset($property->expression)) { + $scope->init[sprintf('$this->__virtual["%s"]', $property->name)]= $property->expression; + } + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/emit/PHP74.class.php b/src/main/php/lang/ast/emit/PHP74.class.php index 72a60b59..6f97959c 100755 --- a/src/main/php/lang/ast/emit/PHP74.class.php +++ b/src/main/php/lang/ast/emit/PHP74.class.php @@ -28,6 +28,8 @@ class PHP74 extends PHP { RewriteThrowableExpressions ; + public $targetVersion= 70400; + /** Sets up type => literal mappings */ public function __construct() { $this->literals= [ diff --git a/src/main/php/lang/ast/emit/PHP80.class.php b/src/main/php/lang/ast/emit/PHP80.class.php index 5cd927b8..db83e745 100755 --- a/src/main/php/lang/ast/emit/PHP80.class.php +++ b/src/main/php/lang/ast/emit/PHP80.class.php @@ -31,6 +31,8 @@ class PHP80 extends PHP { RewriteStaticVariableInitializations ; + public $targetVersion= 80000; + /** Sets up type => literal mappings */ public function __construct() { $this->literals= [ diff --git a/src/main/php/lang/ast/emit/PHP81.class.php b/src/main/php/lang/ast/emit/PHP81.class.php index 70d51ba5..f93b4969 100755 --- a/src/main/php/lang/ast/emit/PHP81.class.php +++ b/src/main/php/lang/ast/emit/PHP81.class.php @@ -23,11 +23,13 @@ class PHP81 extends PHP { RewriteBlockLambdaExpressions, RewriteDynamicClassConstants, RewriteStaticVariableInitializations, + RewriteProperties, ReadonlyClasses, - OmitConstantTypes, - PropertyHooks + OmitConstantTypes ; + public $targetVersion= 80100; + /** Sets up type => literal mappings */ public function __construct() { $this->literals= [ diff --git a/src/main/php/lang/ast/emit/PHP82.class.php b/src/main/php/lang/ast/emit/PHP82.class.php index 0b568d29..d66d1016 100755 --- a/src/main/php/lang/ast/emit/PHP82.class.php +++ b/src/main/php/lang/ast/emit/PHP82.class.php @@ -23,11 +23,13 @@ class PHP82 extends PHP { RewriteBlockLambdaExpressions, RewriteDynamicClassConstants, RewriteStaticVariableInitializations, + RewriteProperties, ReadonlyClasses, - OmitConstantTypes, - PropertyHooks + OmitConstantTypes ; + public $targetVersion= 80200; + /** Sets up type => literal mappings */ public function __construct() { $this->literals= [ diff --git a/src/main/php/lang/ast/emit/PHP83.class.php b/src/main/php/lang/ast/emit/PHP83.class.php index 9285e65d..f33ef341 100755 --- a/src/main/php/lang/ast/emit/PHP83.class.php +++ b/src/main/php/lang/ast/emit/PHP83.class.php @@ -18,7 +18,9 @@ * @see https://wiki.php.net/rfc#php_83 */ class PHP83 extends PHP { - use RewriteBlockLambdaExpressions, ReadonlyClasses, PropertyHooks; + use RewriteBlockLambdaExpressions, RewriteProperties, ReadonlyClasses; + + public $targetVersion= 80300; /** Sets up type => literal mappings */ public function __construct() { diff --git a/src/main/php/lang/ast/emit/PropertyHooks.class.php b/src/main/php/lang/ast/emit/PropertyHooks.class.php index 621edbfb..1859c521 100755 --- a/src/main/php/lang/ast/emit/PropertyHooks.class.php +++ b/src/main/php/lang/ast/emit/PropertyHooks.class.php @@ -1,7 +1,6 @@ private('$name', 'access private'), ...$nodes]); } else if ($modifiers & MODIFIER_PROTECTED) { - $check= ( - '$scope= debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]["class"] ?? null;'. - 'if (__CLASS__ !== $scope && !is_subclass_of($scope, __CLASS__) && \\lang\\VirtualProperty::class !== $scope)'. - 'throw new \\Error("Cannot access protected property ".__CLASS__."::".$name);' - ); + return new Block([$this->protected('$name', 'access protected'), ...$nodes]); } else if (1 === sizeof($nodes)) { return $nodes[0]; } else { return new Block($nodes); } - - return new Block([new Code($check), ...$nodes]); } protected function emitEmulatedHooks($result, $property) { diff --git a/src/main/php/lang/ast/emit/ReadonlyProperties.class.php b/src/main/php/lang/ast/emit/ReadonlyProperties.class.php index 3356b426..82530fec 100755 --- a/src/main/php/lang/ast/emit/ReadonlyProperties.class.php +++ b/src/main/php/lang/ast/emit/ReadonlyProperties.class.php @@ -1,5 +1,15 @@ modifiers)) { - $check= ( - '$scope= debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]["class"] ?? null;'. - 'if (__CLASS__ !== $scope && \\lang\\VirtualProperty::class !== $scope)'. - 'throw new \\Error("Cannot access private property ".__CLASS__."::\\$%1$s");' - ); - } else if (in_array('protected', $property->modifiers)) { - $check= ( - '$scope= debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]["class"] ?? null;'. - 'if (__CLASS__ !== $scope && !is_subclass_of($scope, __CLASS__) && \\lang\\VirtualProperty::class !== $scope)'. - 'throw new \\Error("Cannot access protected property ".__CLASS__."::\\$%1$s");' - ); + if ($modifiers & MODIFIER_PRIVATE) { + $check= $this->private($property->name, 'access private'); + } else if ($modifiers & MODIFIER_PROTECTED) { + $check= $this->protected($property->name, 'access protected'); } else { - $check= ''; + $check= null; } + $virtual= new InstanceExpression(new Variable('this'), new OffsetExpression( + new Literal('__virtual'), + new Literal("'{$property->name}'")) + ); + // Create virtual property implementing the readonly semantics $scope->virtual[$property->name]= [ - new Code(sprintf($check.'return $this->__virtual["%1$s"][0];', $property->name)), - new Code(sprintf( - ($check ?: '$scope= debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]["class"] ?? null;'). - 'if (isset($this->__virtual["%1$s"])) throw new \\Error("Cannot modify readonly property ".__CLASS__."::{$name}");'. - 'if (__CLASS__ !== $scope && \\lang\\VirtualProperty::class !== $scope)'. - 'throw new \\Error("Cannot initialize readonly property ".__CLASS__."::{$name} from ".($scope ? "scope {$scope}": "global scope"));'. - '$this->__virtual["%1$s"]= [$value];', - $property->name - )), + $check ? new Block([$check, new ReturnStatement($virtual)]) : new ReturnStatement($virtual), + new Block([ + $check ?? new Code('$scope= debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]["class"] ?? null;'), + $this->initonce($property->name), + new Code(sprintf( + 'if (__CLASS__ !== $scope && \\lang\\VirtualProperty::class !== $scope)'. + 'throw new \\Error("Cannot initialize readonly property ".__CLASS__."::{$name} from ".($scope ? "scope {$scope}": "global scope"));'. + '$this->__virtual["%1$s"]= [$value];', + $property->name + )), + new Assignment($virtual, '=', new Variable('value')) + ]), ]; } } \ No newline at end of file diff --git a/src/main/php/lang/ast/emit/RewriteProperties.class.php b/src/main/php/lang/ast/emit/RewriteProperties.class.php index 29a0b4d6..3073aad9 100755 --- a/src/main/php/lang/ast/emit/RewriteProperties.class.php +++ b/src/main/php/lang/ast/emit/RewriteProperties.class.php @@ -1,15 +1,26 @@ hooks) { return $this->emitPropertyHooks($result, $property); - } else if (in_array('readonly', $property->modifiers)) { + } else if ( + $this->targetVersion < 80400 && + array_intersect($property->modifiers, ['private(set)', 'protected(set)', 'public(set)']) + ) { + return $this->emitAsymmetricVisibility($result, $property); + } else if ( + $this->targetVersion < 80100 && + in_array('readonly', $property->modifiers) + ) { return $this->emitReadonlyProperties($result, $property); } parent::emitProperty($result, $property); diff --git a/src/main/php/lang/ast/emit/VisibilityChecks.class.php b/src/main/php/lang/ast/emit/VisibilityChecks.class.php new file mode 100755 index 00000000..45672731 --- /dev/null +++ b/src/main/php/lang/ast/emit/VisibilityChecks.class.php @@ -0,0 +1,26 @@ +__virtual["'.$name.'"])) throw new \\Error("Cannot modify readonly property ".__CLASS__."::\$'.$name.'");'); + } + + private function private($name, $access) { + return new Code( + '$scope= debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]["class"] ?? null;'. + 'if (__CLASS__ !== $scope && \\lang\\VirtualProperty::class !== $scope)'. + 'throw new \\Error("Cannot '.$access.' property ".__CLASS__."::\$'.$name.' from ".($scope ? "scope ".$scope : "global scope"));' + ); + } + + private function protected($name, $access) { + return new Code( + '$scope= debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]["class"] ?? null;'. + 'if (__CLASS__ !== $scope && !is_subclass_of($scope, __CLASS__) && \\lang\\VirtualProperty::class !== $scope)'. + 'throw new \\Error("Cannot '.$access.' property ".__CLASS__."::\$'.$name.' from ".($scope ? "scope ".$scope : "global scope"));' + ); + } +} \ No newline at end of file diff --git a/src/test/php/lang/ast/unittest/emit/AsymmetricVisibilityTest.class.php b/src/test/php/lang/ast/unittest/emit/AsymmetricVisibilityTest.class.php new file mode 100755 index 00000000..1c327efe --- /dev/null +++ b/src/test/php/lang/ast/unittest/emit/AsymmetricVisibilityTest.class.php @@ -0,0 +1,126 @@ +declare('class %T { + public private(set) string $fixture= "Test"; + }'); + Assert::equals('Test', $t->newInstance()->fixture); + } + + #[Test] + public function writing_from_self_scope() { + $t= $this->declare('class %T { + public private(set) string $fixture= "Test"; + + public function rename($name) { + $this->fixture= $name; + return $this; + } + }'); + + Assert::throws(Error::class, fn() => $t->newInstance()->fixture= 'Changed'); + Assert::equals('Changed', $t->newInstance()->rename('Changed')->fixture); + } + + #[Test] + public function writing_from_inherited_scope() { + $parent= $this->declare('class %T { public protected(set) string $fixture= "Test"; }'); + $t= $this->declare('class %T extends '.$parent->literal().' { + public function rename($name) { + $this->fixture= $name; + return $this; + } + }'); + + Assert::throws(Error::class, fn() => $t->newInstance()->fixture= 'Changed'); + Assert::equals('Changed', $t->newInstance()->rename('Changed')->fixture); + } + + #[Test] + public function writing_explicitely_public_set() { + $t= $this->declare('class %T { + public public(set) string $fixture= "Test"; + }'); + + $instance= $t->newInstance(); + $instance->fixture= 'Changed'; + Assert::equals('Changed', $instance->fixture); + } + + #[Test, Expect(class: Error::class, message: '/Cannot modify private\(set\) property T.+::\$fixture/')] + public function writing_private() { + $t= $this->declare('class %T { + public private(set) string $fixture= "Test"; + }'); + $t->newInstance()->fixture= 'Changed'; + } + + #[Test, Expect(class: Error::class, message: '/Cannot modify protected\(set\) property T.+::\$fixture/')] + public function writing_protected() { + $t= $this->declare('class %T { + public protected(set) string $fixture= "Test"; + }'); + $t->newInstance()->fixture= 'Changed'; + } + + #[Test] + public function promoted_constructor_parameter() { + $t= $this->declare('class %T { + public function __construct(public private(set) string $fixture) { } + }'); + Assert::equals('Test', $t->newInstance('Test')->fixture); + } + + #[Test, Expect(class: Error::class, message: '/Cannot modify readonly property .+fixture/')] + public function readonly() { + $t= $this->declare('class %T { + + // public-read, protected-write, write-once property + public protected(set) readonly string $fixture; + + public function __construct() { + $this->fixture= "Test"; + } + + public function rename() { + $this->fixture= "Changed"; // Will always error + } + }'); + $t->newInstance()->rename(); + } + + #[Test, Values(['private', 'protected'])] + public function reflection($modifier) { + $t= $this->declare('class %T { + public '.$modifier.'(set) string $fixture= "Test"; + }'); + + Assert::equals( + 'public '.$modifier.'(set) string $fixture', + $t->property('fixture')->toString() + ); + } + + #[Test, Values(['private', 'protected', 'public'])] + public function same_modifier_for_get_and_set($modifier) { + $t= $this->declare('class %T { + '.$modifier.' '.$modifier.'(set) string $fixture= "Test"; + }'); + + Assert::equals( + $modifier.' string $fixture', + $t->property('fixture')->toString() + ); + } +} \ No newline at end of file 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 a998a7dd..c65e4a66 100755 --- a/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/ReadonlyTest.class.php @@ -151,7 +151,7 @@ public function can_be_assigned_via_reflection() { Assert::equals('Test', $i->fixture); } - #[Test, Expect(class: Error::class, message: '/Cannot initialize readonly property .+fixture/')] + #[Test, Expect(class: Error::class, message: '/Cannot (initialize readonly|modify protected\(set\)) property .+fixture/')] public function cannot_initialize_from_outside() { $t= $this->declare('class %T { public readonly string $fixture;