Skip to content

Commit

Permalink
Merge pull request #166 from xp-framework/feature/property-hooks
Browse files Browse the repository at this point in the history
Implement property hooks via virtual properties
  • Loading branch information
thekid committed Jun 15, 2024
2 parents b74ec9a + e605a97 commit 9b67405
Show file tree
Hide file tree
Showing 14 changed files with 659 additions and 42 deletions.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
32 changes: 32 additions & 0 deletions src/main/php/lang/ast/emit/ChainScopeOperators.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php namespace lang\ast\emit;

use lang\ast\Node;
use lang\ast\nodes\Literal;

/**
* Allows chaining of scope operators and rewrites `[expr]::class` to `get_class($object)`
* except if expression references a type - e.g. `self` or `ClassName`.
*
* @see https://wiki.php.net/rfc/class_name_literal_on_object
* @see https://wiki.php.net/rfc/variable_syntax_tweaks#constant_dereferencability
* @test lang.ast.unittest.emit.ChainScopeOperatorsTest
*/
trait ChainScopeOperators {
use RewriteDynamicClassConstants { emitScope as rewriteDynamicClassConstants; }

protected function emitScope($result, $scope) {
if (!($scope->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);
}
}
}
16 changes: 8 additions & 8 deletions src/main/php/lang/ast/emit/PHP.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
Expression,
InstanceExpression,
Literal,
NewExpression,
Property,
ScopeExpression,
UnpackExpression,
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -1084,15 +1085,14 @@ protected function emitInvoke($result, $invoke) {

protected function emitScope($result, $scope) {

// $x::<expr> vs. e.g. invoke()::<expr> vs. T::<expr>
if ($scope->type instanceof Variable) {
// new T()::<expr> vs. e.g. $x::<expr> vs. T::<expr>
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}::");
}
Expand All @@ -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(')->');
Expand Down
4 changes: 2 additions & 2 deletions src/main/php/lang/ast/emit/PHP74.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
;
Expand Down
2 changes: 1 addition & 1 deletion src/main/php/lang/ast/emit/PHP80.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ class PHP80 extends PHP {
CallablesAsClosures,
OmitConstantTypes,
ReadonlyClasses,
ReadonlyProperties,
RewriteBlockLambdaExpressions,
RewriteDynamicClassConstants,
RewriteEnums,
RewriteExplicitOctals,
RewriteProperties,
RewriteStaticVariableInitializations
;

Expand Down
3 changes: 2 additions & 1 deletion src/main/php/lang/ast/emit/PHP81.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ class PHP81 extends PHP {
RewriteDynamicClassConstants,
RewriteStaticVariableInitializations,
ReadonlyClasses,
OmitConstantTypes
OmitConstantTypes,
PropertyHooks
;

/** Sets up type => literal mappings */
Expand Down
3 changes: 2 additions & 1 deletion src/main/php/lang/ast/emit/PHP82.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ class PHP82 extends PHP {
RewriteDynamicClassConstants,
RewriteStaticVariableInitializations,
ReadonlyClasses,
OmitConstantTypes
OmitConstantTypes,
PropertyHooks
;

/** Sets up type => literal mappings */
Expand Down
2 changes: 1 addition & 1 deletion src/main/php/lang/ast/emit/PHP83.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
229 changes: 229 additions & 0 deletions src/main/php/lang/ast/emit/PropertyHooks.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
<?php namespace lang\ast\emit;

use ReflectionProperty;
use lang\ast\Code;
use lang\ast\nodes\{
Assignment,
Block,
InstanceExpression,
InvokeExpression,
Literal,
Method,
OffsetExpression,
Parameter,
ReturnStatement,
ScopeExpression,
Signature,
Variable
};

/**
* Property hooks
*
* @see https://wiki.php.net/rfc/property-hooks
* @test lang.ast.unittest.emit.PropertyHooksTest
*/
trait PropertyHooks {

protected function rewriteHook($node, $name, $virtual, $literal) {

// Magic constant referencing property name
if ($node instanceof Literal && '__PROPERTY__' === $node->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;

// <T>::$field::hook() => <T>::__<hook>_<field>()
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);
}
}
}
2 changes: 1 addition & 1 deletion src/main/php/lang/ast/emit/ReadonlyProperties.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}");'.
Expand Down
Loading

0 comments on commit 9b67405

Please sign in to comment.