diff --git a/composer.json b/composer.json index 303b7c68..ed40d3f1 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.0 | ^2.13", - "xp-framework/ast": "^11.0 | ^10.1", + "xp-framework/ast": "^11.1", "php" : ">=7.4.0" }, "require-dev" : { diff --git a/src/main/php/lang/ast/emit/ChainScopeOperators.class.php b/src/main/php/lang/ast/emit/ChainScopeOperators.class.php new file mode 100755 index 00000000..13bd4411 --- /dev/null +++ b/src/main/php/lang/ast/emit/ChainScopeOperators.class.php @@ -0,0 +1,32 @@ +type instanceof Node)) return $this->rewriteDynamicClassConstants($result, $scope); + + if ($scope->member instanceof Literal && 'class' === $scope->member->expression) { + $result->out->write('\\get_class('); + $this->emitOne($result, $scope->type); + $result->out->write(')'); + } else { + $t= $result->temp(); + $result->out->write('(null==='.$t.'='); + $this->emitOne($result, $scope->type); + $result->out->write(")?null:{$t}::"); + $this->emitOne($result, $scope->member); + } + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/emit/PHP.class.php b/src/main/php/lang/ast/emit/PHP.class.php index ac3d93a4..d83c5cc7 100755 --- a/src/main/php/lang/ast/emit/PHP.class.php +++ b/src/main/php/lang/ast/emit/PHP.class.php @@ -11,6 +11,7 @@ Expression, InstanceExpression, Literal, + NewExpression, Property, ScopeExpression, UnpackExpression, @@ -462,7 +463,7 @@ protected function emitClass($result, $class) { } $result->out->write('];'); - $result->out->write('public function __get($name) { switch ($name) {'); + $result->out->write('public function &__get($name) { switch ($name) {'); foreach ($context->virtual as $name => $access) { $result->out->write($name ? 'case "'.$name.'":' : 'default:'); $this->emitOne($result, $access[0]); @@ -1084,15 +1085,14 @@ protected function emitInvoke($result, $invoke) { protected function emitScope($result, $scope) { - // $x:: vs. e.g. invoke():: vs. T:: - if ($scope->type instanceof Variable) { + // new T():: vs. e.g. $x:: vs. T:: + if ($scope->type instanceof NewExpression) { + $result->out->write('('); $this->emitOne($result, $scope->type); - $result->out->write('::'); + $result->out->write(')::'); } else if ($scope->type instanceof Node) { - $t= $result->temp(); - $result->out->write('(null==='.$t.'='); $this->emitOne($result, $scope->type); - $result->out->write(")?null:{$t}::"); + $result->out->write('::'); } else { $result->out->write("{$scope->type}::"); } @@ -1111,7 +1111,7 @@ protected function emitScope($result, $scope) { } protected function emitInstance($result, $instance) { - if ('new' === $instance->expression->kind) { + if ($instance->expression instanceof NewExpression) { $result->out->write('('); $this->emitOne($result, $instance->expression); $result->out->write(')->'); diff --git a/src/main/php/lang/ast/emit/PHP74.class.php b/src/main/php/lang/ast/emit/PHP74.class.php index 77fe94a1..72a60b59 100755 --- a/src/main/php/lang/ast/emit/PHP74.class.php +++ b/src/main/php/lang/ast/emit/PHP74.class.php @@ -13,17 +13,17 @@ class PHP74 extends PHP { ArrayUnpackUsingMerge, AttributesAsComments, CallablesAsClosures, + ChainScopeOperators, MatchAsTernaries, NonCapturingCatchVariables, NullsafeAsTernaries, OmitArgumentNames, OmitConstantTypes, ReadonlyClasses, - ReadonlyProperties, RewriteBlockLambdaExpressions, - RewriteClassOnObjects, RewriteEnums, RewriteExplicitOctals, + RewriteProperties, RewriteStaticVariableInitializations, RewriteThrowableExpressions ; diff --git a/src/main/php/lang/ast/emit/PHP80.class.php b/src/main/php/lang/ast/emit/PHP80.class.php index 48f37fd0..5cd927b8 100755 --- a/src/main/php/lang/ast/emit/PHP80.class.php +++ b/src/main/php/lang/ast/emit/PHP80.class.php @@ -23,11 +23,11 @@ class PHP80 extends PHP { CallablesAsClosures, OmitConstantTypes, ReadonlyClasses, - ReadonlyProperties, RewriteBlockLambdaExpressions, RewriteDynamicClassConstants, RewriteEnums, RewriteExplicitOctals, + RewriteProperties, RewriteStaticVariableInitializations ; diff --git a/src/main/php/lang/ast/emit/PHP81.class.php b/src/main/php/lang/ast/emit/PHP81.class.php index ce52501f..70d51ba5 100755 --- a/src/main/php/lang/ast/emit/PHP81.class.php +++ b/src/main/php/lang/ast/emit/PHP81.class.php @@ -24,7 +24,8 @@ class PHP81 extends PHP { RewriteDynamicClassConstants, RewriteStaticVariableInitializations, ReadonlyClasses, - OmitConstantTypes + OmitConstantTypes, + PropertyHooks ; /** Sets up type => literal mappings */ diff --git a/src/main/php/lang/ast/emit/PHP82.class.php b/src/main/php/lang/ast/emit/PHP82.class.php index 5e508762..0b568d29 100755 --- a/src/main/php/lang/ast/emit/PHP82.class.php +++ b/src/main/php/lang/ast/emit/PHP82.class.php @@ -24,7 +24,8 @@ class PHP82 extends PHP { RewriteDynamicClassConstants, RewriteStaticVariableInitializations, ReadonlyClasses, - OmitConstantTypes + OmitConstantTypes, + PropertyHooks ; /** Sets up type => literal mappings */ diff --git a/src/main/php/lang/ast/emit/PHP83.class.php b/src/main/php/lang/ast/emit/PHP83.class.php index 3be2fb23..9285e65d 100755 --- a/src/main/php/lang/ast/emit/PHP83.class.php +++ b/src/main/php/lang/ast/emit/PHP83.class.php @@ -18,7 +18,7 @@ * @see https://wiki.php.net/rfc#php_83 */ class PHP83 extends PHP { - use RewriteBlockLambdaExpressions, ReadonlyClasses; + use RewriteBlockLambdaExpressions, ReadonlyClasses, PropertyHooks; /** 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 new file mode 100755 index 00000000..621edbfb --- /dev/null +++ b/src/main/php/lang/ast/emit/PropertyHooks.class.php @@ -0,0 +1,229 @@ +expression) return $literal; + + // Rewrite $this->propertyName to virtual property + if ( + $node instanceof InstanceExpression && + $node->expression instanceof Variable && 'this' === $node->expression->pointer && + $node->member instanceof Literal && $name === $node->member->expression + ) return $virtual; + + // ::$field::hook() => ::___() + if ( + $node instanceof ScopeExpression && + $node->member instanceof InvokeExpression && + $node->member->expression instanceof Literal && + $node->type instanceof ScopeExpression && + $node->type->member instanceof Variable && + is_string($node->type->type) && + is_string($node->type->member->pointer) + ) { + return new ScopeExpression($node->type->type, new InvokeExpression( + new Literal('__'.$node->member->expression->expression.'_'.$node->type->member->pointer), + $node->member->arguments + )); + } + + foreach ($node->children() as &$child) { + $child= $this->rewriteHook($child, $name, $virtual, $literal); + } + return $node; + } + + protected function withScopeCheck($modifiers, $nodes) { + if ($modifiers & MODIFIER_PRIVATE) { + $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__."::".$name);' + ); + } 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);' + ); + } 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) { + static $lookup= [ + 'public' => MODIFIER_PUBLIC, + 'protected' => MODIFIER_PROTECTED, + 'private' => MODIFIER_PRIVATE, + 'static' => MODIFIER_STATIC, + 'final' => MODIFIER_FINAL, + 'abstract' => MODIFIER_ABSTRACT, + 'readonly' => 0x0080, // XP 10.13: MODIFIER_READONLY + ]; + + // Emit XP meta information for the reflection API + $scope= $result->codegen->scope[0]; + $modifiers= 0; + foreach ($property->modifiers as $name) { + $modifiers|= $lookup[$name]; + } + + $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 => ['interface' === $scope->type->kind ? $modifiers | MODIFIER_ABSTRACT : $modifiers] + ]; + + $literal= new Literal("'{$property->name}'"); + $virtual= new InstanceExpression(new Variable('this'), new OffsetExpression(new Literal('__virtual'), $literal)); + + // Emit get and set hooks in-place. Ignore any unknown hooks + $get= $set= null; + foreach ($property->hooks as $type => $hook) { + $method= '__'.$type.'_'.$property->name; + $modifierList= $modifiers & MODIFIER_ABSTRACT ? ['abstract'] : $hook->modifiers; + if ('get' === $type) { + $this->emitOne($result, new Method( + $modifierList, + $method, + new Signature([], null, $hook->byref), + null === $hook->expression ? null : [$this->rewriteHook( + $hook->expression instanceof Block ? $hook->expression : new ReturnStatement($hook->expression), + $property->name, + $virtual, + $literal + )], + null // $hook->annotations + )); + $get= $this->withScopeCheck($modifiers, [ + new Assignment(new Variable('r'), $hook->byref ? '=&' : '=', new InvokeExpression( + new InstanceExpression(new Variable('this'), new Literal($method)), + [] + )), + new ReturnStatement(new Variable('r')) + ]); + } else if ('set' === $type) { + $this->emitOne($result, new Method( + $modifierList, + $method, + new Signature($hook->parameter ? [$hook->parameter] : [new Parameter('value', null)], null), + null === $hook->expression ? null : [$this->rewriteHook( + $hook->expression instanceof Block ? $hook->expression : new Assignment($virtual, '=', $hook->expression), + $property->name, + $virtual, + $literal + )], + null // $hook->annotations + )); + $set= $this->withScopeCheck($modifiers, [new InvokeExpression( + new InstanceExpression(new Variable('this'), new Literal($method)), + [new Variable('value')] + )]); + } + } + + // Declare virtual properties with __set and __get as well as initializations + // except inside interfaces, which cannot contain properties. + if ('interface' === $scope->type->kind) return; + + $scope->virtual[$property->name]= [ + $get ?? new ReturnStatement($virtual), + $set ?? new Assignment($virtual, '=', new Variable('value')) + ]; + if (isset($property->expression)) { + $scope->init[sprintf('$this->__virtual["%s"]', $property->name)]= $property->expression; + } + } + + protected function emitNativeHooks($result, $property) { + $result->codegen->scope[0]->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 => [] + ]; + + $property->comment && $this->emitOne($result, $property->comment); + $property->annotations && $this->emitOne($result, $property->annotations); + $result->at($property->declared)->out->write(implode(' ', $property->modifiers).' '.$this->propertyType($property->type).' $'.$property->name); + if (isset($property->expression)) { + if ($this->isConstant($result, $property->expression)) { + $result->out->write('='); + $this->emitOne($result, $property->expression); + } else if (in_array('static', $property->modifiers)) { + $result->codegen->scope[0]->statics['self::$'.$property->name]= $property->expression; + } else { + $result->codegen->scope[0]->init['$this->'.$property->name]= $property->expression; + } + } + + // TODO move this to lang.ast.emit.PHP once https://github.com/php/php-src/pull/13455 is merged + $result->out->write('{'); + foreach ($property->hooks as $type => $hook) { + $hook->byref && $result->out->write('&'); + $result->out->write($type); + if ($hook->parameter) { + $result->out->write('('); + $this->emitOne($result, $hook->parameter); + $result->out->write(')'); + } + + if (null === $hook->expression) { + $result->out->write(';'); + } else if ($hook->expression instanceof Block) { + $this->emitOne($result, $hook->expression); + } else { + $result->out->write('=>'); + $this->emitOne($result, $hook->expression); + $result->out->write(';'); + } + } + $result->out->write('}'); + } + + protected function emitProperty($result, $property) { + static $hooks= null; + + if (empty($property->hooks)) { + parent::emitProperty($result, $property); + } else if ($hooks ?? $hooks= method_exists(ReflectionProperty::class, 'getHooks')) { + $this->emitNativeHooks($result, $property); + } else { + $this->emitEmulatedHooks($result, $property); + } + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/emit/ReadonlyProperties.class.php b/src/main/php/lang/ast/emit/ReadonlyProperties.class.php index 5dcbee40..3356b426 100755 --- a/src/main/php/lang/ast/emit/ReadonlyProperties.class.php +++ b/src/main/php/lang/ast/emit/ReadonlyProperties.class.php @@ -55,7 +55,7 @@ protected function emitProperty($result, $property) { // Create virtual property implementing the readonly semantics $scope->virtual[$property->name]= [ - new Code(sprintf($check.'return $this->__virtual["%1$s"][0] ?? null;', $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}");'. diff --git a/src/main/php/lang/ast/emit/RewriteClassOnObjects.class.php b/src/main/php/lang/ast/emit/RewriteClassOnObjects.class.php deleted file mode 100755 index c1da8601..00000000 --- a/src/main/php/lang/ast/emit/RewriteClassOnObjects.class.php +++ /dev/null @@ -1,23 +0,0 @@ -member instanceof Literal && 'class' === $scope->member->expression && !is_string($scope->type)) { - $result->out->write('\\get_class('); - $this->emitOne($result, $scope->type); - $result->out->write(')'); - } else { - $this->rewriteDynamicClassConstants($result, $scope); - } - } -} \ 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 new file mode 100755 index 00000000..29a0b4d6 --- /dev/null +++ b/src/main/php/lang/ast/emit/RewriteProperties.class.php @@ -0,0 +1,17 @@ +hooks) { + return $this->emitPropertyHooks($result, $property); + } else if (in_array('readonly', $property->modifiers)) { + return $this->emitReadonlyProperties($result, $property); + } + parent::emitProperty($result, $property); + } +} \ No newline at end of file diff --git a/src/test/php/lang/ast/unittest/emit/RewriteClassOnObjectsTest.class.php b/src/test/php/lang/ast/unittest/emit/ChainScopeOperatorsTest.class.php similarity index 84% rename from src/test/php/lang/ast/unittest/emit/RewriteClassOnObjectsTest.class.php rename to src/test/php/lang/ast/unittest/emit/ChainScopeOperatorsTest.class.php index 147bde28..7e874e67 100755 --- a/src/test/php/lang/ast/unittest/emit/RewriteClassOnObjectsTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/ChainScopeOperatorsTest.class.php @@ -1,16 +1,16 @@ run('class %T { + public $test { get => "Test"; } + + public function run() { + return $this->test; + } + }'); + + Assert::equals('Test', $r); + } + + #[Test] + public function get_block() { + $r= $this->run('class %T { + public $test { get { return "Test"; } } + + public function run() { + return $this->test; + } + }'); + + Assert::equals('Test', $r); + } + + #[Test] + public function abbreviated_get() { + $r= $this->run('class %T { + private $word= "Test"; + private $interpunction= "!"; + + public $test => $this->word.$this->interpunction; + + public function run() { + return $this->test; + } + }'); + + Assert::equals('Test!', $r); + } + + #[Test] + public function set_expression() { + $r= $this->run('class %T { + public $test { set => ucfirst($value); } + + public function run() { + $this->test= "test"; + return $this->test; + } + }'); + + Assert::equals('Test', $r); + } + + #[Test] + public function set_block() { + $r= $this->run('class %T { + public $test { set { $this->test= ucfirst($value); } } + + public function run() { + $this->test= "test"; + return $this->test; + } + }'); + + Assert::equals('Test', $r); + } + + #[Test, Expect(IllegalArgumentException::class)] + public function set_raising_exception() { + $this->run('use lang\\IllegalArgumentException; class %T { + public $test { set { throw new IllegalArgumentException("Cannot set"); } } + + public function run() { + $this->test= "test"; + } + }'); + } + + #[Test] + public function get_and_set_using_property() { + $r= $this->run('class %T { + public $test { + get => $this->test; + set => ucfirst($value); + } + + public function run() { + $this->test= "test"; + return $this->test; + } + }'); + + Assert::equals('Test', $r); + } + + #[Test] + public function implicit_set() { + $r= $this->run('class %T { + public $test { + get => ucfirst($this->test); + } + + public function run() { + $this->test= "test"; + return $this->test; + } + }'); + + Assert::equals('Test', $r); + } + + #[Test] + public function typed_set() { + $r= $this->run('use util\\Bytes; class %T { + public string $test { + set(string|Bytes $arg) => ucfirst($arg); + } + + public function run() { + $this->test= new Bytes(["t", "e", "s", "t"]); + return $this->test; + } + }'); + + Assert::equals('Test', $r); + } + + #[Test, Expect(class: Error::class, message: '/Argument .+ type string, array given/')] + public function typed_mismatch() { + $this->run('class %T { + public string $test { + set(string $times) => $times." times"; + } + + public function run() { + $this->test= []; + } + }'); + } + + #[Test] + public function initial_value() { + $r= $this->run('class %T { + public $test= "test" { + get => ucfirst($this->test); + } + + public function run() { + return $this->test; + } + }'); + + Assert::equals('Test', $r); + } + + #[Test] + public function by_reference_supports_array_modifications() { + $r= $this->run('class %T { + private $list= []; + public $test { + &get => $this->list; + } + + public function run() { + $this->test[]= "Test"; + return $this->test; + } + }'); + + Assert::equals(['Test'], $r); + } + + #[Test] + public function property_constant() { + $r= $this->run('class %T { + public $test { get => __PROPERTY__; } + + public function run() { + return $this->test; + } + }'); + + Assert::equals('test', $r); + } + + #[Test] + public function reflection() { + $t= $this->declare('class %T { + public string $test { + get => $this->test; + set => ucfirst($value); + } + }'); + + Assert::equals('public string $test', $t->property('test')->toString()); + } + + #[Test] + public function abstract_property() { + $t= $this->declare('abstract class %T { + public abstract string $test { get; set; } + }'); + + Assert::equals('public abstract string $test', $t->property('test')->toString()); + } + + #[Test] + public function reflective_get() { + $t= $this->declare('class %T { + public string $test { get => "Test"; } + }'); + + $instance= $t->newInstance(); + Assert::equals('Test', $t->property('test')->get($instance)); + } + + #[Test] + public function reflective_set() { + $t= $this->declare('class %T { + public string $test { set => ucfirst($value); } + }'); + + $instance= $t->newInstance(); + $t->property('test')->set($instance, 'test'); + Assert::equals('Test', $instance->test); + } + + #[Test] + public function interface_hook() { + $t= $this->declare('interface %T { + public string $test { get; } + }'); + + Assert::equals('public abstract string $test', $t->property('test')->toString()); + } + + #[Test] + public function line_number_in_thrown_expression() { + $r= $this->run('use lang\\IllegalArgumentException; class %T { + public $test { + set($name) { + if (strlen($name) > 10) throw new IllegalArgumentException("Too long"); + $this->test= $name; + } + } + + public function run() { + try { + $this->test= "this is too long"; + return null; + } catch (IllegalArgumentException $expected) { + return $expected->getLine(); + } + } + }'); + + Assert::equals(4, $r); + } + + #[Test] + public function accessing_private_property() { + $r= $this->run('class %T { + private string $test { get => "Test"; } + + public function run() { + return $this->test; + } + }'); + + Assert::equals('Test', $r); + } + + #[Test] + public function accessing_protected_property() { + $r= $this->run('class %T { + protected string $test { get => "Test"; } + + public function run() { + return $this->test; + } + }'); + + Assert::equals('Test', $r); + } + + #[Test, Expect(class: Error::class, message: '/Cannot access private property .+test/')] + public function accessing_private_property_from_outside() { + $r= $this->run('class %T { + private string $test { get => "Test"; } + + public function run() { + return $this; + } + }'); + + $r->test; + } + + #[Test, Expect(class: Error::class, message: '/Cannot access protected property .+test/')] + public function accessing_protected_property_from_outside() { + $r= $this->run('class %T { + protected string $test { get => "Test"; } + + public function run() { + return $this; + } + }'); + + $r->test; + } + + #[Test] + public function accessing_private_property_reflectively() { + $t= $this->declare('class %T { + private string $test { get => "Test"; } + }'); + + Assert::equals('Test', $t->property('test')->get($t->newInstance(), $t)); + } + + #[Test] + public function accessing_protected_property_reflectively() { + $t= $this->declare('class %T { + protected string $test { get => "Test"; } + }'); + + Assert::equals('Test', $t->property('test')->get($t->newInstance(), $t)); + } + + #[Test] + public function get_parent_hook() { + $base= $this->declare('class %T { + public string $test { get => "Test"; } + }'); + $r= $this->run('class %T extends '.$base->literal().' { + public string $test { get => parent::$test::get()."!"; } + + public function run() { + return $this->test; + } + }'); + + Assert::equals('Test!', $r); + } +} \ No newline at end of file