From 3e9c02a666938ffe49911fb54647f3821d4bc1bf Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Sat, 11 Jul 2026 12:50:43 +0800 Subject: [PATCH] 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 --- docs/INCOMPATIBLE_PHP_FEATURES.md | 3 +- docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md | 1 - phpunit/code/private-set-property.php | 12 ++ phpunit/code/protected-set-property.php | 12 ++ phpunit/src/ClassTest.php | 10 + src/CompilerBase.php | 103 ++++++++- src/Entity/PropertyDef.php | 12 ++ src/Parser/AssignOpTrait.php | 47 ++++ src/Preprocessor.php | 13 +- src/PropertyHookLowering.php | 204 ++++++++++++++++++ src/Translator.php | 62 ++++-- src/Visitor.php | 26 +++ src/gen_stub.php | 24 ++- .../asymmetric-set-visibility.phpt | 83 +++++++ .../property-hooks-operations.phpt | 40 ++++ tests/aot/object_property/property-hooks.phpt | 40 ++++ 16 files changed, 662 insertions(+), 30 deletions(-) create mode 100644 phpunit/code/private-set-property.php create mode 100644 phpunit/code/protected-set-property.php create mode 100644 src/PropertyHookLowering.php create mode 100644 tests/aot/object_property/asymmetric-set-visibility.phpt create mode 100644 tests/aot/object_property/property-hooks-operations.phpt create mode 100644 tests/aot/object_property/property-hooks.phpt diff --git a/docs/INCOMPATIBLE_PHP_FEATURES.md b/docs/INCOMPATIBLE_PHP_FEATURES.md index 6dc9e363..12839154 100644 --- a/docs/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/INCOMPATIBLE_PHP_FEATURES.md @@ -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`允许,但会直接丢弃此默认参数)。 diff --git a/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md b/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md index 52ed18dc..9e78044c 100644 --- a/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md +++ b/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md @@ -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. | diff --git a/phpunit/code/private-set-property.php b/phpunit/code/private-set-property.php new file mode 100644 index 00000000..8a98f479 --- /dev/null +++ b/phpunit/code/private-set-property.php @@ -0,0 +1,12 @@ +name = 'outside'; +} diff --git a/phpunit/code/protected-set-property.php b/phpunit/code/protected-set-property.php new file mode 100644 index 00000000..f5ed1988 --- /dev/null +++ b/phpunit/code/protected-set-property.php @@ -0,0 +1,12 @@ +score = 1; +} diff --git a/phpunit/src/ClassTest.php b/phpunit/src/ClassTest.php index 32f47743..a2c5de87 100644 --- a/phpunit/src/ClassTest.php +++ b/phpunit/src/ClassTest.php @@ -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'); diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 8b104d8e..cead8bd1 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.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 diff --git a/src/Entity/PropertyDef.php b/src/Entity/PropertyDef.php index 01144c50..157bed61 100644 --- a/src/Entity/PropertyDef.php +++ b/src/Entity/PropertyDef.php @@ -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); + } } diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index ce11561d..fb7bbab7 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -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; diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 64fcc4de..a6e67d61 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -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; diff --git a/src/PropertyHookLowering.php b/src/PropertyHookLowering.php new file mode 100644 index 00000000..e4748dde --- /dev/null +++ b/src/PropertyHookLowering.php @@ -0,0 +1,204 @@ + */ + 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 */ + 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 */ + 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); + } +} diff --git a/src/Translator.php b/src/Translator.php index e6e38c45..4328fc01 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -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(); + $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) { + $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); + } + } + $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 .= $classDef->ctorInit; - $code .= "auto obj = create_object_{$className}(class_type);\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); - } + $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 .= $classDef->ctorClean; - $code .= "return obj;\n};\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; diff --git a/src/Visitor.php b/src/Visitor.php index 5bc28d15..1ccf8f47 100644 --- a/src/Visitor.php +++ b/src/Visitor.php @@ -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; + } } diff --git a/src/gen_stub.php b/src/gen_stub.php index 4e5b5df9..2deb057d 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -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 { diff --git a/tests/aot/object_property/asymmetric-set-visibility.phpt b/tests/aot/object_property/asymmetric-set-visibility.phpt new file mode 100644 index 00000000..3325b427 --- /dev/null +++ b/tests/aot/object_property/asymmetric-set-visibility.phpt @@ -0,0 +1,83 @@ +--TEST-- +Asymmetric property set visibility works for static and dynamic writes +--FILE-- +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) diff --git a/tests/aot/object_property/property-hooks-operations.phpt b/tests/aot/object_property/property-hooks-operations.phpt new file mode 100644 index 00000000..cef1aa30 --- /dev/null +++ b/tests/aot/object_property/property-hooks-operations.phpt @@ -0,0 +1,40 @@ +--TEST-- +PHP 8.4 property hooks support implicit value, compound writes and dynamic reads +--FILE-- + $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) diff --git a/tests/aot/object_property/property-hooks.phpt b/tests/aot/object_property/property-hooks.phpt new file mode 100644 index 00000000..81c13efe --- /dev/null +++ b/tests/aot/object_property/property-hooks.phpt @@ -0,0 +1,40 @@ +--TEST-- +PHP 8.4 property hooks lower to getter and setter methods +--FILE-- + 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)