feat(parser): add support for PHP 8.4 property hooks and asymmetric visibility

- Implement property hook lowering to getter/setter method calls
- Add support for private(set) and protected(set) asymmetric visibility
- Generate appropriate error handling for read-only hooked properties
- Support compound assignments and increment/decrement operations on hooked properties
- Add proper visibility checks for asymmetric property setters
- Update documentation to reflect new property hook capabilities
- Add comprehensive test coverage for property hook functionality
pull/17/head
韩天峰 2 months ago
parent bd2a3da14c
commit 3e9c02a666
  1. 3
      docs/INCOMPATIBLE_PHP_FEATURES.md
  2. 1
      docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md
  3. 12
      phpunit/code/private-set-property.php
  4. 12
      phpunit/code/protected-set-property.php
  5. 10
      phpunit/src/ClassTest.php
  6. 103
      src/CompilerBase.php
  7. 12
      src/Entity/PropertyDef.php
  8. 47
      src/Parser/AssignOpTrait.php
  9. 13
      src/Preprocessor.php
  10. 204
      src/PropertyHookLowering.php
  11. 60
      src/Translator.php
  12. 26
      src/Visitor.php
  13. 24
      src/gen_stub.php
  14. 83
      tests/aot/object_property/asymmetric-set-visibility.phpt
  15. 40
      tests/aot/object_property/property-hooks-operations.phpt
  16. 40
      tests/aot/object_property/property-hooks.phpt

@ -14,7 +14,8 @@
## 声明与类型
- 不支持可变变量 `$$var`
- 不支持 PHP 8.4 property hooks。
- PHP 8.4 property hooks 会降级为 AOT getter/setter;直接属性读写和动态对象读写均受支持。当前不支持对 hook 属性取引用。
- 支持 `private(set)``protected(set)` 非对称属性可见性;在 PHP 8.2/8.3 后端通过自定义属性写 handler 执行同等作用域检查。
- 不支持闭包或箭头函数按引用返回。
- `__construct()` 不允许返回值。
- 参数默认值不允许出现在必填参数之前(`PHP`允许,但会直接丢弃此默认参数)。

@ -78,7 +78,6 @@ These items should be documented with the exact boundary.
| Feature | Classification | Implementation Direction |
|---|---|---|
| Variable variables (`$$var`) | Pending | Add a function-local symbol table mirror for dynamic locals, and disable or synchronize native locals that escape into dynamic lookup. |
| PHP 8.4 property hooks | Pending | Add parser and AST support, then lower property read/write paths to hook calls. |
| Closure or arrow function returning by reference | Pending | Closure metadata and wrappers must preserve return-by-reference and emit `ReturnRef`. |
| Closure and arrow function by-reference parameters | Pending | Closure arginfo must preserve by-reference parameters and call lowering must pass reference slots. |
| By-reference variadic parameters (`&...$args`) | Pending | Variadic storage must preserve references instead of copying values. |

@ -0,0 +1,12 @@
<?php
class PrivateSetRecord
{
public private(set) string $name = 'default';
}
function main(): void
{
$record = new PrivateSetRecord();
$record->name = 'outside';
}

@ -0,0 +1,12 @@
<?php
class ProtectedSetRecord
{
public protected(set) int $score = 0;
}
function main(): void
{
$record = new ProtectedSetRecord();
$record->score = 1;
}

@ -12,6 +12,16 @@ class ClassTest extends \BaseTest
$this->exec('Cannot access protected property `settings` of class `DevConfig`', 'protected-property.php');
}
public function testCannotWritePrivateSetPropertyOutsideDeclaringClass()
{
$this->exec('Cannot modify private(set) property', 'private-set-property.php');
}
public function testCannotWriteProtectedSetPropertyOutsideClassFamily()
{
$this->exec('Cannot modify protected(set) property', 'protected-set-property.php');
}
public function testCallAbstractParentMethod()
{
$this->exec('Cannot call abstract method `AbsBase::show()`', 'parent-abstract-method.php');

@ -3146,11 +3146,36 @@ class CompilerBase implements PropertyAccessContext
*/
protected function genDynamicPropIncDec($var, string $op, bool $isPre): ?string
{
if (!$this->isPropertyFetch($var) || $this->isNativePropertyAccess($var)) {
if (!$this->isPropertyFetch($var)) {
return null;
}
$target = $this->preparePropertyWriteTarget($var);
$getter = $this->getPropertyHookGetter($var);
$setter = $this->getPropertyHookSetter($var);
if ($getter !== null && $setter === null) {
$this->fatalError($var, 'Cannot write to read-only hooked property');
}
if ($getter !== null && $setter !== null) {
$tmpVar = $this->genTmpVarName();
$this->addLocalVar($tmpVar, self::TYPE_VAR);
$read = $this->emitPropertyHookGetterCall($var, $getter);
if ($isPre) {
$set = $this->emitPropertyHookSetterCall($var, $setter, new Expr\Variable($tmpVar));
$this->context->beforeStmtLines[] = "{$tmpVar} = {$read} {$op} 1; {$set};";
} else {
$nextVar = $this->genTmpVarName();
$this->addLocalVar($nextVar, self::TYPE_VAR);
$set = $this->emitPropertyHookSetterCall($var, $setter, new Expr\Variable($nextVar));
$this->context->beforeStmtLines[] = "{$tmpVar} = {$read};";
$this->context->afterStmtLines[] = "{$nextVar} = {$tmpVar} {$op} 1; {$set};";
}
return $tmpVar;
}
if ($this->isNativePropertyAccess($var)) {
return null;
}
$tmpVar = $this->genTmpVarName();
$this->addLocalVar($tmpVar, self::TYPE_VAR);
if ($isPre) {
@ -5370,6 +5395,7 @@ class CompilerBase implements PropertyAccessContext
}
if ($this->isIdExpr($left->name)) {
$this->getPropertyIdentifier($left, $left->var, $left->name);
$this->assertPropertySetVisibility($left);
}
return new PropertyWriteTarget($left, 'object property', $objectExpr, $propertyExpr);
}
@ -5377,6 +5403,7 @@ class CompilerBase implements PropertyAccessContext
if ($left instanceof Expr\StaticPropertyFetch) {
if ($this->isIdExpr($left->name)) {
$this->resolveNativeStaticPropertyFetch($left);
$this->assertPropertySetVisibility($left);
}
return new PropertyWriteTarget($left, 'static property');
}
@ -5384,6 +5411,27 @@ class CompilerBase implements PropertyAccessContext
return null;
}
private function assertPropertySetVisibility(NodeAbstract $property): void
{
if ($this->isPropertyHookBackingAccess($property)) {
return;
}
$access = $this->getNativePropertyAccess($property);
if ($access === null) {
return;
}
$def = $access->getPropertyDef();
$declaringClass = $access->resolution->declaringClass;
$scope = $this->class ? $this->getFullClassName() : '';
$propertyName = $this->parseIdentifier($property->name);
if ($def->isPrivateSet() && !$this->isSameClassName($scope, $declaringClass)) {
$this->fatalError($property, "Cannot modify private(set) property `{$declaringClass}::\${$propertyName}`");
}
if ($def->isProtectedSet() && !$this->canAccessProtectedProperty($scope, $declaringClass)) {
$this->fatalError($property, "Cannot modify protected(set) property `{$declaringClass}::\${$propertyName}`");
}
}
protected function assertCanAssignPropertyWrite(PropertyWriteTarget $target, Expr $right): void
{
$this->assertCanAssignObjectProperty($target->node, $right, $target->label);
@ -5750,10 +5798,15 @@ class CompilerBase implements PropertyAccessContext
return $this->parseNullsafeExpr($expr);
}
$update = $this->isPropertyFetchUpdate($expr);
$object = $expr->var;
$property = $expr->name;
$id = $this->getPropertyIdentifier($expr, $object, $property);
$hook = $this->getPropertyHookGetter($expr);
if ($hook !== null) {
return $this->emitPropertyHookGetterCall($expr, $hook);
}
$update = $this->isPropertyFetchUpdate($expr);
$objectName = $update ? $this->parseWritableIdentifier($object) : $this->parseIdentifier($object);
if ($this->isVarExpr($object) and !$this->hasVar($objectName)) {
$this->errorUndefinedVariable($object);
@ -5781,6 +5834,48 @@ class CompilerBase implements PropertyAccessContext
return $getProperty;
}
protected function isPropertyHookBackingAccess(NodeAbstract $expr): bool
{
return $expr->getAttribute(PropertyHookLowering::BACKING_ACCESS_ATTRIBUTE, false) === true;
}
protected function getPropertyHookGetter(NodeAbstract $expr): ?string
{
if ($this->isPropertyHookBackingAccess($expr)) {
return null;
}
return $this->getNativePropertyDef($expr)?->getter;
}
protected function getPropertyHookSetter(NodeAbstract $expr): ?string
{
if ($this->isPropertyHookBackingAccess($expr)) {
return null;
}
return $this->getNativePropertyDef($expr)?->setter;
}
protected function isReadOnlyPropertyHook(NodeAbstract $expr): bool
{
if ($this->isPropertyHookBackingAccess($expr)) {
return false;
}
$def = $this->getNativePropertyDef($expr);
return $def !== null && $def->getter !== null && $def->setter === null;
}
protected function emitPropertyHookGetterCall(Expr\PropertyFetch $expr, string $getter): string
{
$call = new Expr\MethodCall($expr->var, $getter, [], $expr->getAttributes());
return $this->parseMethodCall($call);
}
protected function emitPropertyHookSetterCall(Expr\PropertyFetch $expr, string $setter, Expr $value): string
{
$call = new Expr\MethodCall($expr->var, $setter, [new Node\Arg($value)], $expr->getAttributes());
return $this->parseMethodCall($call);
}
private function emitNativeInstancePropertyTypedFetch(
Expr\PropertyFetch $expr,
string $objectVar,
@ -6415,7 +6510,9 @@ class CompilerBase implements PropertyAccessContext
protected function emitDynamicPropertyWrite(string $object, string $property, string $value): string
{
return "{$object}.setProperty({$property}, {$value})";
$scope = $this->class ? $this->getClassEntryPtr($this->getFullClassName()) : 'nullptr';
return 'typephp_write_property_scoped('
. $object . ', ' . $property . ', ' . $value . ', ' . $scope . ')';
}
protected function emitDynamicPropertyTargetRead(PropertyWriteTarget $target): string

@ -22,6 +22,8 @@ class PropertyDef
public array $typeCheck = [];
public string $typeStr = '';
public bool $promoted = false;
public ?string $getter = null;
public ?string $setter = null;
public function __construct(string $name, int $flags, string $type, ?string $default = null, bool $nullable = false)
{
@ -51,4 +53,14 @@ class PropertyDef
{
return $this->flags & Modifiers::STATIC;
}
public function isPrivateSet(): bool
{
return (bool) ($this->flags & Modifiers::PRIVATE_SET);
}
public function isProtectedSet(): bool
{
return (bool) ($this->flags & Modifiers::PROTECTED_SET);
}
}

@ -152,6 +152,13 @@ trait AssignOpTrait
$type = self::TYPE_VAR;
}
if ($left instanceof Expr\PropertyFetch && ($setter = $this->getPropertyHookSetter($left)) !== null) {
return $this->parseAssignPropertyHook($left, $right, $propertyWriteTarget, $setter);
}
if ($left instanceof Expr\PropertyFetch && $this->isReadOnlyPropertyHook($left)) {
$this->fatalError($left, 'Cannot write to read-only hooked property');
}
if ($propertyWriteTarget !== null && $this->shouldUseDynamicNativePropertyWrite($left, $type)) {
return $this->parseAssignPropertyFetch($left, $right, $propertyWriteTarget);
}
@ -309,6 +316,25 @@ trait AssignOpTrait
}
}
protected function parseAssignPropertyHook(
Expr\PropertyFetch $left,
Expr $right,
?PropertyWriteTarget $target,
string $setter,
): string {
if ($target !== null) {
$this->assertCanAssignPropertyWrite($target, $right);
}
$rightExpr = $this->parseExprAsValue($right);
if ($target !== null) {
$rightExpr = $this->wrapPropertyWriteTypeCheck($target, $right, $rightExpr);
}
$tmp = $this->genTmpVarName();
$this->addLocalVar($tmp, self::TYPE_VAR);
$call = $this->emitPropertyHookSetterCall($left, $setter, new Expr\Variable($tmp));
return '((' . $tmp . ' = ' . $rightExpr . ', ' . $call . '), ' . $tmp . ')';
}
protected function shouldUseDynamicNativePropertyWrite(Expr $left, string $rightType): bool
{
if (!$this->isPropertyFetch($left)) {
@ -362,6 +388,27 @@ trait AssignOpTrait
$propertyWriteTarget = $this->preparePropertyWriteTarget($node->var);
$this->guardLiteralDivisionByZero($node->expr, $op);
if ($node->var instanceof Expr\PropertyFetch && $this->isReadOnlyPropertyHook($node->var)) {
$this->fatalError($node->var, 'Cannot write to read-only hooked property');
}
if ($node->var instanceof Expr\PropertyFetch
&& ($setter = $this->getPropertyHookSetter($node->var)) !== null
&& ($getter = $this->getPropertyHookGetter($node->var)) !== null) {
$right = $this->parseExprAsValue($node->expr);
$read = $this->emitPropertyHookGetterCall($node->var, $getter);
$tmp = $this->genTmpVarName();
$this->addLocalVar($tmp, self::TYPE_VAR);
$binaryOp = $this->removeAssignOp($op);
$value = match ($binaryOp) {
'.' => 'php::concat(' . $read . ', ' . $right . ')',
'**' => 'php::fn::pow(' . $read . ', ' . $right . ')',
default => $read . ' ' . $binaryOp . ' (' . $right . ')',
};
$call = $this->emitPropertyHookSetterCall($node->var, $setter, new Expr\Variable($tmp));
return '((' . $tmp . ' = ' . $value . ', ' . $call . '), ' . $tmp . ')';
}
$nativePropertyAssignOp = $this->parseNativePropertyAssignOp($node, $op);
if ($nativePropertyAssignOp !== null) {
return $nativePropertyAssignOp;

@ -719,16 +719,21 @@ class Preprocessor extends CompilerBase
protected function parseClassPropertyDef(Node\Stmt\Property $v): void
{
if ($v->hooks and count($v->hooks) > 0) {
$this->fatalError($v, 'The class property hooks are not supported');
}
$oriCtx = $this->context;
$this->context = $this->classDef->propertyContext;
$nullable = $v->type instanceof NullableType;
foreach ($v->props as $prop) {
$propName = $this->parseIdentifier($prop->name);
$this->addClassProperty($propName, $v->flags, $v->type, $prop->default, $nullable, $v);
$propDef = $this->addClassProperty($propName, $v->flags, $v->type, $prop->default, $nullable, $v);
foreach ($v->hooks as $hook) {
$kind = strtolower($hook->name->toString());
if ($kind === 'get') {
$propDef->getter = PropertyHookLowering::getterName($propName);
} elseif ($kind === 'set') {
$propDef->setter = PropertyHookLowering::setterName($propName);
}
}
}
$this->context = $oriCtx;

@ -0,0 +1,204 @@
<?php
/**
* This file is part of TypePHP.
*
* @link https://www.swoole.com/
* @contact service@swoole.com
*/
namespace TypePhp;
use PhpParser\Modifiers;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Param;
use PhpParser\Node\Stmt;
use PhpParser\NodeAbstract;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
final class PropertyHookLowering
{
public const string BACKING_ACCESS_ATTRIBUTE = 'typephpPropertyHookBackingAccess';
public const string METHOD_ATTRIBUTE = 'typephpPropertyHookMethod';
private const string GET_PREFIX = '__typephp_property_get_';
private const string SET_PREFIX = '__typephp_property_set_';
private const string PRIVATE_SET_PREFIX = '__typephp_property_private_set_';
private const string PROTECTED_SET_PREFIX = '__typephp_property_protected_set_';
public static function getterName(string $property): string
{
return self::GET_PREFIX . bin2hex($property);
}
public static function setterName(string $property): string
{
return self::SET_PREFIX . bin2hex($property);
}
public static function isGetterName(string $method): bool
{
return str_starts_with($method, self::GET_PREFIX);
}
public static function isSetterName(string $method): bool
{
return str_starts_with($method, self::SET_PREFIX);
}
public static function isAsymmetricSetterMarkerName(string $method): bool
{
return str_starts_with($method, self::PRIVATE_SET_PREFIX)
|| str_starts_with($method, self::PROTECTED_SET_PREFIX);
}
/** @return list<Stmt\ClassMethod> */
public static function lowerProperty(Stmt\Property $property): array
{
if (count($property->props) !== 1) {
return [];
}
$propertyName = $property->props[0]->name->toString();
$methods = [];
if ($property->flags & Modifiers::PRIVATE_SET) {
$methods[] = self::visibilityMarker(
self::PRIVATE_SET_PREFIX . bin2hex($propertyName),
$property->getAttributes()
);
} elseif ($property->flags & Modifiers::PROTECTED_SET) {
$methods[] = self::visibilityMarker(
self::PROTECTED_SET_PREFIX . bin2hex($propertyName),
$property->getAttributes()
);
}
foreach ($property->hooks as $hook) {
$kind = strtolower($hook->name->toString());
if ($kind !== 'get' && $kind !== 'set') {
continue;
}
self::markBackingAccesses($hook->body, $propertyName);
if ($kind === 'get') {
$stmts = self::getterStatements($hook, $propertyName);
$params = [];
$returnType = $property->type;
$methodName = self::getterName($propertyName);
} else {
$params = $hook->params;
if ($params === []) {
$params = [new Param(new Expr\Variable('value'), type: $property->type)];
} elseif ($params[0]->type === null) {
$params[0]->type = $property->type;
}
$stmts = self::setterStatements($hook, $propertyName, $params[0]);
$returnType = new Node\Identifier('void');
$methodName = self::setterName($propertyName);
}
$method = new Stmt\ClassMethod($methodName, [
'flags' => Modifiers::PUBLIC | Modifiers::FINAL,
'byRef' => $kind === 'get' && $hook->byRef,
'params' => $params,
'returnType' => $returnType,
'stmts' => $stmts,
'attrGroups' => $hook->attrGroups,
], $hook->getAttributes());
$method->setAttribute(self::METHOD_ATTRIBUTE, [
'kind' => $kind,
'property' => $propertyName,
]);
$methods[] = $method;
}
return $methods;
}
public static function lowerPromotedProperty(Param $param): ?Stmt\ClassMethod
{
if (!$param->isPromoted() || !is_string($param->var->name)) {
return null;
}
if ($param->flags & Modifiers::PRIVATE_SET) {
$prefix = self::PRIVATE_SET_PREFIX;
} elseif ($param->flags & Modifiers::PROTECTED_SET) {
$prefix = self::PROTECTED_SET_PREFIX;
} else {
return null;
}
return self::visibilityMarker($prefix . bin2hex($param->var->name), $param->getAttributes());
}
private static function visibilityMarker(string $name, array $attributes): Stmt\ClassMethod
{
return new Stmt\ClassMethod($name, [
'flags' => Modifiers::PUBLIC | Modifiers::FINAL,
'returnType' => new Node\Identifier('void'),
'stmts' => [],
], $attributes);
}
/** @return list<Stmt> */
private static function getterStatements(Node\PropertyHook $hook, string $property): array
{
if ($hook->body === null) {
return [new Stmt\Return_(self::backingFetch($property))];
}
if ($hook->body instanceof Expr) {
return [new Stmt\Return_($hook->body, $hook->body->getAttributes())];
}
return $hook->body;
}
/** @return list<Stmt> */
private static function setterStatements(Node\PropertyHook $hook, string $property, Param $param): array
{
if ($hook->body instanceof Expr) {
return [new Stmt\Expression(
new Expr\Assign(self::backingFetch($property), $hook->body),
$hook->body->getAttributes()
)];
}
if ($hook->body !== null) {
return $hook->body;
}
return [new Stmt\Expression(new Expr\Assign(
self::backingFetch($property),
new Expr\Variable($param->var->name)
))];
}
private static function backingFetch(string $property): Expr\PropertyFetch
{
$fetch = new Expr\PropertyFetch(new Expr\Variable('this'), $property);
$fetch->setAttribute(self::BACKING_ACCESS_ATTRIBUTE, true);
return $fetch;
}
private static function markBackingAccesses(NodeAbstract|array|null $body, string $property): void
{
if ($body === null) {
return;
}
$nodes = is_array($body) ? $body : [$body];
$traverser = new NodeTraverser();
$traverser->addVisitor(new class($property) extends NodeVisitorAbstract {
public function __construct(private readonly string $property)
{
}
public function enterNode(Node $node): null
{
if ($node instanceof Expr\PropertyFetch
&& $node->var instanceof Expr\Variable
&& $node->var->name === 'this'
&& $node->name instanceof Node\Identifier
&& $node->name->toString() === $this->property) {
$node->setAttribute(PropertyHookLowering::BACKING_ACCESS_ATTRIBUTE, true);
}
return null;
}
});
$traverser->traverse($nodes);
}
}

@ -988,8 +988,9 @@ CODE;
$code .= "// class \n";
foreach ($this->getClassLikesWithConstants() as $classDef) {
if ($classDef instanceof ClassDef && $classDef->requireCtor) {
if ($classDef instanceof ClassDef && !$classDef->trait && !$classDef->enum) {
$code .= 'static zend_object* (*create_object_' . $classDef->getNamespacedName() . ")(zend_class_entry *class_type);\n";
$code .= 'static zend_object_handlers property_handlers_' . $classDef->getNamespacedName() . ";\n";
}
foreach ($classDef->constants as $constant) {
if ($constant->type === self::TYPE_ARRAY) {
@ -2034,22 +2035,41 @@ CODE;
/**
* @var ClassDef $classDef
*/
if ($classDef and $classDef->requireCtor) {
if ($classDef && !$classDef->trait && !$classDef->enum) {
$className = $classDef->getNamespacedName();
$code .= "create_object_{$className} = php_get_create_object_fn({$ce});\n";
$code .= "{$ce}->create_object = [](zend_class_entry *class_type) -> zend_object* {\n";
$code .= $classDef->ctorInit;
$code .= "auto obj = create_object_{$className}(class_type);\n";
$handlers = "property_handlers_{$className}";
$buildCreateBody = function (bool $attachHandlers) use ($classDef, $className, $handlers): string {
$body = $classDef->ctorInit;
$body .= "auto obj = create_object_{$className}(class_type);\n";
if ($attachHandlers) {
$body .= "typephp_attach_property_handlers(obj, &{$handlers});\n";
}
foreach ($classDef->properties as $property) {
if (!$property->isStatic() && $property->arrayInitPlan && $property->default) {
$body = "auto value = {$property->arrayInitPlan->expr};\n";
$body .= 'zend_update_property(obj->ce, obj, ' . $this->genZendStrl($property->name) . ", value.ptr());\n";
$body .= "php::throwErrorIfOccurred();\n";
$code .= $this->wrapArrayInitPlan($property->arrayInitPlan, $body);
$init = "auto value = {$property->arrayInitPlan->expr};\n";
$init .= 'zend_update_property(obj->ce, obj, ' . $this->genZendStrl($property->name) . ", value.ptr());\n";
$init .= "php::throwErrorIfOccurred();\n";
$body .= $this->wrapArrayInitPlan($property->arrayInitPlan, $init);
}
}
$code .= $classDef->ctorClean;
$code .= "return obj;\n};\n";
$body .= $classDef->ctorClean;
return $body . "return obj;\n";
};
$code .= "typephp_install_property_handlers({$ce}, &{$handlers});\n";
$code .= "#if (PHP_VERSION_ID < 80400)\n";
$code .= "create_object_{$className} = php_get_create_object_fn({$ce});\n";
$code .= "{$ce}->create_object = [](zend_class_entry *class_type) -> zend_object* {\n";
$code .= $buildCreateBody(true);
$code .= "};\n";
if ($classDef->requireCtor) {
$code .= "#else\n";
$code .= "create_object_{$className} = php_get_create_object_fn({$ce});\n";
$code .= "{$ce}->create_object = [](zend_class_entry *class_type) -> zend_object* {\n";
$code .= $buildCreateBody(false);
$code .= "};\n";
}
$code .= "#endif\n";
}
}
return $code;
@ -4131,6 +4151,11 @@ CODE;
"Declaration of `{$className}::\${$name}` must be compatible " .
"with `{$parentClass}::\${$name}`");
}
if ($this->getPropertySetVisibilityRank($childProp) < $this->getPropertySetVisibilityRank($parentProp)) {
$this->fatalError($classStmt,
"Declaration of `{$className}::\${$name}` must not restrict set visibility " .
"of `{$parentClass}::\${$name}`");
}
if (($childProp->flags & Modifiers::READONLY) !== ($parentProp->flags & Modifiers::READONLY)) {
$this->fatalError($classStmt,
"Declaration of `{$className}::\${$name}` must be compatible " .
@ -4141,6 +4166,17 @@ CODE;
}
}
private function getPropertySetVisibilityRank(PropertyDef $property): int
{
if ($property->isPrivateSet()) {
return 1;
}
if ($property->isProtectedSet()) {
return 2;
}
return $this->getVisibilityRank($property->flags);
}
private function checkConstantOverride(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ $classStmt): void
{
$classDef = $this->classDef;

@ -8,8 +8,34 @@
namespace TypePhp;
use PhpParser\Node;
use PhpParser\Node\Stmt;
use PhpParser\NodeVisitorAbstract;
class Visitor extends NodeVisitorAbstract
{
public function leaveNode(Node $node): null
{
if (!$node instanceof Stmt\Class_ && !$node instanceof Stmt\Trait_ && !$node instanceof Stmt\Enum_) {
return null;
}
$methods = [];
foreach ($node->stmts as $stmt) {
if ($stmt instanceof Stmt\Property) {
array_push($methods, ...PropertyHookLowering::lowerProperty($stmt));
} elseif ($stmt instanceof Stmt\ClassMethod && $stmt->name->toLowerString() === '__construct') {
foreach ($stmt->params as $param) {
$marker = PropertyHookLowering::lowerPromotedProperty($param);
if ($marker !== null) {
$methods[] = $marker;
}
}
}
}
if ($methods !== []) {
array_push($node->stmts, ...$methods);
}
return null;
}
}

@ -3336,9 +3336,14 @@ class PropertyInfo extends VariableLike
$template .= "zend_declare_property_ex(class_entry, $nameCode, &$zvalName, %s, $commentCode);\n";
}
$minimumFlagCompatibility = $this->phpVersionIdMinimumCompatibility;
if (($this->flags & (Modifiers::PRIVATE_SET | Modifiers::PROTECTED_SET))
&& ($minimumFlagCompatibility === null || $minimumFlagCompatibility >= PHP_84_VERSION_ID)) {
$minimumFlagCompatibility = PHP_82_VERSION_ID;
}
$code .= $this->getFlagsByPhpVersion()->generateVersionDependentFlagCode(
$template,
$this->phpVersionIdMinimumCompatibility
$minimumFlagCompatibility
);
$code .= $stringRelease;
@ -3364,6 +3369,12 @@ class PropertyInfo extends VariableLike
$flags->addForVersionsAbove("ZEND_ACC_READONLY", PHP_82_VERSION_ID);
}
if ($this->flags & Modifiers::PRIVATE_SET) {
$flags->addForVersionsAbove("ZEND_ACC_PRIVATE_SET", PHP_84_VERSION_ID);
} elseif ($this->flags & Modifiers::PROTECTED_SET) {
$flags->addForVersionsAbove("ZEND_ACC_PROTECTED_SET", PHP_84_VERSION_ID);
}
if ($this->isVirtual) {
$flags->addForVersionsAbove("ZEND_ACC_VIRTUAL", PHP_84_VERSION_ID);
}
@ -3643,7 +3654,9 @@ class ClassInfo {
$php80MinimumCompatibility = $this->phpVersionIdMinimumCompatibility === null || $this->phpVersionIdMinimumCompatibility >= PHP_80_VERSION_ID;
$php81MinimumCompatibility = $this->phpVersionIdMinimumCompatibility === null || $this->phpVersionIdMinimumCompatibility >= PHP_81_VERSION_ID;
$php84MinimumCompatibility = $this->phpVersionIdMinimumCompatibility === null || $this->phpVersionIdMinimumCompatibility >= PHP_84_VERSION_ID;
// TypePHP classes may target a pre-8.4 Zend runtime even when parsing
// newer PHP syntax, so class registration retains the old API branch.
$php84MinimumCompatibility = false;
if ($this->type === "enum" && !$php81MinimumCompatibility) {
$code .= "#if (PHP_VERSION_ID >= " . PHP_81_VERSION_ID . ")\n";
@ -3795,12 +3808,6 @@ class ClassInfo {
$code .= $php80CondEnd;
}
$code .= "\n\tstatic zend_object_handlers class_object_handlers;";
$code .= "\n\tmemcpy(&class_object_handlers, class_entry->default_object_handlers, sizeof(zend_object_handlers));";
$code .= "\n\tclass_object_handlers.unset_property = typephp_unset_typed_property;";
$code .= "\n\tclass_entry->default_object_handlers = &class_object_handlers;";
$code .= "\n";
$code .= "\n\treturn class_entry;\n";
$code .= "}\n";
@ -4461,6 +4468,7 @@ class FileInfo {
public static function parseStubFile(string $code, string $phpVersion = '8.5'): FileInfo {
$parser = (new PhpParser\ParserFactory())->createForVersion(PhpParser\PhpVersion::fromString($phpVersion));
$nodeTraverser = new PhpParser\NodeTraverser;
$nodeTraverser->addVisitor(new TypePhp\Visitor());
$nodeTraverser->addVisitor(new PhpParser\NodeVisitor\NameResolver);
$prettyPrinter = new class extends Standard {
protected function pName_FullyQualified(PhpParser\Node\Name\FullyQualified $node): string {

@ -0,0 +1,83 @@
--TEST--
Asymmetric property set visibility works for static and dynamic writes
--FILE--
<?php
class BaseRecord
{
public private(set) string $name = 'default';
public protected(set) int $score = 0;
public function rename(string $name): void
{
$this->name = $name;
}
public function renameDynamically(mixed $target, string $name): void
{
$target->name = $name;
}
public function setScore(int $score): void
{
$this->score = $score;
}
public function setRelatedChildScore(mixed $target, int $score): void
{
$target->childScore = $score;
}
}
class ChildRecord extends BaseRecord
{
public protected(set) int $childScore = 0;
public function setOwnScore(int $score): void
{
$this->score = $score;
}
public function setRelatedScore(mixed $target, int $score): void
{
$target->score = $score;
}
}
function tryExternalWrites(mixed $record): void
{
try {
$record->name = 'outside';
} catch (Error $error) {
echo "private blocked\n";
}
try {
$record->score = 99;
} catch (Error $error) {
echo "protected blocked\n";
}
}
function main(): void
{
$record = new ChildRecord();
$record->rename('inside');
$record->renameDynamically($record, 'dynamic inside');
$record->setScore(10);
$record->setOwnScore(15);
$record->setRelatedScore($record, 20);
$record->setRelatedChildScore($record, 30);
var_dump($record->name, $record->score, $record->childScore);
tryExternalWrites($record);
var_dump($record->name, $record->score, $record->childScore);
}
?>
--EXPECT--
string(14) "dynamic inside"
int(20)
int(30)
private blocked
protected blocked
string(14) "dynamic inside"
int(20)
int(30)

@ -0,0 +1,40 @@
--TEST--
PHP 8.4 property hooks support implicit value, compound writes and dynamic reads
--FILE--
<?php
class Counter
{
public int $value {
get => $this->value * 2;
set => max(0, $value);
}
}
function readDynamically(mixed $counter): mixed
{
return $counter->value;
}
function writeDynamically(mixed $counter, mixed $value): void
{
$counter->value = $value;
}
function main(): void
{
$counter = new Counter();
$counter->value = 3;
$counter->value += 2;
var_dump($counter->value);
var_dump($counter->value++);
var_dump(readDynamically($counter));
writeDynamically($counter, -10);
var_dump(readDynamically($counter));
}
?>
--EXPECT--
int(16)
int(16)
int(34)
int(0)

@ -0,0 +1,40 @@
--TEST--
PHP 8.4 property hooks lower to getter and setter methods
--FILE--
<?php
class Person
{
public string $name {
get => strtoupper($this->name);
set(string $value) => trim($value);
}
public int $age {
get {
return $this->age + 1;
}
set(int $value) {
$this->age = $value;
}
}
}
function setNameDynamically(mixed $person): void
{
$person->name = ' bob ';
}
function main(): void
{
$person = new Person();
$person->name = ' alice ';
$person->age = 20;
setNameDynamically($person);
var_dump($person->name);
var_dump($person->age);
}
?>
--EXPECT--
string(3) "BOB"
int(21)
Loading…
Cancel
Save