From 795a8e0a1daa4f354922f4bc873490768773b420 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Mon, 24 Aug 2026 17:54:10 +0800 Subject: [PATCH] feat(compiler): add PHP 8.5 clone-with support and scope handling - Implement clone-with functionality with one/two argument support - Add preservation of evaluation order for clone-with operations - Support dynamic, numeric and object-valued properties in clone-with - Handle error cases and stop property updates at first error - Reject active references and unwrap sole remaining references - Respect property scope and unlock readonly properties during cloning - Add first-class callable and string callable support for clone - Preserve lexical class scope in Zend call frames for method calls - Update documentation with clone-with compatibility requirements - Add comprehensive test coverage for clone-with features - Implement scope argument handling in runtime function calls --- docs/INCOMPATIBLE_PHP_FEATURES.md | 1 + docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md | 2 + phpunit/code/clone-with-codegen.php | 16 +++ phpunit/src/CloneWithCodegenTest.php | 62 ++++++++++++ src/CompilerBase.php | 7 +- src/Parser/FunctionCallTrait.php | 20 +++- src/Translator.php | 4 + tests/compiler/basic/clone-with-basic.phpt | 98 +++++++++++++++++++ tests/compiler/basic/clone-with-callable.phpt | 36 +++++++ .../basic/clone-with-dynamic-properties.phpt | 36 +++++++ tests/compiler/basic/clone-with-errors.phpt | 57 +++++++++++ .../compiler/basic/clone-with-references.phpt | 31 ++++++ .../basic/clone-with-scope-readonly.phpt | 96 ++++++++++++++++++ 13 files changed, 462 insertions(+), 4 deletions(-) create mode 100644 phpunit/code/clone-with-codegen.php create mode 100644 phpunit/src/CloneWithCodegenTest.php create mode 100644 tests/compiler/basic/clone-with-basic.phpt create mode 100644 tests/compiler/basic/clone-with-callable.phpt create mode 100644 tests/compiler/basic/clone-with-dynamic-properties.phpt create mode 100644 tests/compiler/basic/clone-with-errors.phpt create mode 100644 tests/compiler/basic/clone-with-references.phpt create mode 100644 tests/compiler/basic/clone-with-scope-readonly.phpt diff --git a/docs/INCOMPATIBLE_PHP_FEATURES.md b/docs/INCOMPATIBLE_PHP_FEATURES.md index f457d127..8571c1ea 100644 --- a/docs/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/INCOMPATIBLE_PHP_FEATURES.md @@ -16,6 +16,7 @@ - 不支持可变变量 `$$var`。 - 暂不支持 PHP 8.5 `#[NoDiscard]`。 - 支持 PHP 8.5 `(void)` 显式丢弃语句;操作数仍会求值并保留副作用,不能在赋值、返回、参数或条件等值上下文中使用。 +- PHP 8.5 `clone()` / clone-with 依赖实际链接的 `libphp` 版本不低于 8.5。普通公开属性、动态属性、调用顺序、错误传播和 callable 路径已有 PHPT;TypePHP 内部类的 private/protected/readonly 更新仍是 XFAIL,property hook 类的自定义 object handlers 也尚未兼容 `clone_obj_with`。 - PHP 8.4 property hooks 会编译为 AOT getter/setter,并注册对应的 Zend hook 元数据;直接属性读写、Reflection 和对象遍历均受支持。当前不支持对 hook 属性取引用。 - PHP 8.4 Reflection Lazy Object 不能用于 TypePHP AOT 类。AOT 类以 persistent internal class 注册,而 Zend 的 `zend_object_make_lazy()` 明确拒绝 internal class;运行时动态加载的 ZendPHP user class 不受此限制。 - 支持 `private(set)` 与 `protected(set)` 非对称属性可见性,并通过 PHP 8.4+ 的类级对象 handler 执行同等作用域检查。 diff --git a/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md b/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md index 800d0ecb..cdbbea61 100644 --- a/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md +++ b/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md @@ -81,6 +81,8 @@ These items should be documented with the exact boundary. | Feature | Classification | Implementation Direction | |---|---|---| +| PHP 8.5 `#[NoDiscard]` | Pending | Preserve PHP's warning behavior and timing consistently for native direct calls and Zend runtime fallbacks. TypePHP's compile-time `#[MustUse]` remains a separate feature. | +| PHP 8.5 clone-with for scoped/readonly/hooked TypePHP properties | Pending | Preserve the lexical class scope in the Zend call frame and teach TypePHP's custom object handlers to clone through `clone_obj_with` without losing hook handlers. Public/dynamic properties and callable/error paths already delegate correctly to a PHP 8.5 runtime. | | 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. | | Closure or arrow function returning by reference | Pending | Closure metadata and wrappers must preserve return-by-reference and emit `ReturnRef`. | | PHP 8.5 closures in constants, parameter defaults or property defaults | Pending | Use context-aware runtime initializers: cache constants and property defaults per request, create parameter defaults per omitted call, and never place request-local zvals in persistent MINIT storage. | diff --git a/phpunit/code/clone-with-codegen.php b/phpunit/code/clone-with-codegen.php new file mode 100644 index 00000000..ffb079e6 --- /dev/null +++ b/phpunit/code/clone-with-codegen.php @@ -0,0 +1,16 @@ + 2]); + } +} + +function clone_with_global(CloneWithCodegen $value): CloneWithCodegen +{ + return clone($value, []); +} diff --git a/phpunit/src/CloneWithCodegenTest.php b/phpunit/src/CloneWithCodegenTest.php new file mode 100644 index 00000000..b8955f02 --- /dev/null +++ b/phpunit/src/CloneWithCodegenTest.php @@ -0,0 +1,62 @@ +compileFixture(); + $method = $this->functionBody($code, 'php_clonewithcodegen__copy'); + + self::assertMatchesRegularExpression( + '/php::call\(get_persistent_class\([^;]+?, get_(?:persistent_)?func\(/', + $method, + ); + } + + public function testGlobalCloneWithDoesNotInventClassScope(): void + { + $code = $this->compileFixture(); + $function = $this->functionBody($code, 'php_clone_with_global'); + + self::assertMatchesRegularExpression('/php::call\(get_(?:persistent_)?func\(/', $function); + self::assertStringNotContainsString('php::call(get_persistent_class(', $function); + } + + private function compileFixture(): string + { + global $translator; + + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $source = ROOT_PATH . '/phpunit/code/clone-with-codegen.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + return $code; + } + + private function functionBody(string $code, string $function): string + { + $start = strpos($code, $function . '('); + self::assertIsInt($start); + $end = strpos($code, "\n}\n", $start); + self::assertIsInt($end); + + return substr($code, $start, $end - $start + 3); + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 6bc8f850..e14c2d45 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -3420,9 +3420,12 @@ class CompilerBase implements PropertyAccessContext array $args, string $funcName = '', string $className = '', - bool $separateNamedArgs = true + bool $separateNamedArgs = true, + string $scope = '', ): string { - return 'php::call(' . $callable . ', ' . $this->parseCallArgs($args, $funcName, $className, $separateNamedArgs) . ')'; + $scopeArg = $scope === '' ? '' : $scope . ', '; + return 'php::call(' . $scopeArg . $callable . ', ' + . $this->parseCallArgs($args, $funcName, $className, $separateNamedArgs) . ')'; } protected function genRuntimeObjectMethodCall( diff --git a/src/Parser/FunctionCallTrait.php b/src/Parser/FunctionCallTrait.php index 5a59ca57..d70ffc4e 100644 --- a/src/Parser/FunctionCallTrait.php +++ b/src/Parser/FunctionCallTrait.php @@ -71,6 +71,7 @@ trait FunctionCallTrait protected function parseFuncCall(Expr\FuncCall $expr): string { + $runtimeCallScope = null; $this->validateImmutableCall($expr); $pythonCall = $this->parsePythonFunctionCall($expr); if ($pythonCall !== null) { @@ -100,6 +101,15 @@ trait FunctionCallTrait } elseif ($expr->name->getType() === 'Name' or $expr->name->getType() === 'Name_FullyQualified') { $name = $this->parseIdentifier($expr->name); $globalName = ltrim($name, '\\'); + if ($globalName === 'clone' && !$expr->isFirstClassCallable() && $this->class) { + // PHP 8.5 clone-with applies property updates in the lexical + // scope of the call site. Direct AOT method calls do not leave + // a Zend execute frame on top, so preserve that scope while + // invoking the builtin clone() implementation. + $runtimeCallScope = $this->classDef?->trait + ? 'php::FakeScopeGuard::current()' + : $this->getClassEntryPtr($this->getFullClassName()); + } if ($globalName === 'get_called_class' && $this->classDef?->nativeObject) { $this->fatalError( $expr, @@ -200,10 +210,16 @@ trait FunctionCallTrait $name = ''; } if (empty($expr->args)) { - return 'php::call(' . $fn . ')'; + $scopeArg = $runtimeCallScope === null ? '' : $runtimeCallScope . ', '; + return 'php::call(' . $scopeArg . $fn . ')'; } try { - return $this->genRuntimeFunctionCall($fn, $expr->args, $name); + return $this->genRuntimeFunctionCall( + $fn, + $expr->args, + $name, + scope: $runtimeCallScope ?? '', + ); } catch (PlaceHolder) { return $this->genPlaceHolder($placeHolder); } diff --git a/src/Translator.php b/src/Translator.php index d435588a..72b629c5 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -2214,6 +2214,10 @@ CODE; return $body . "return obj;\n"; }; + // TODO(PHP 8.5 clone-with): typephp_install_property_handlers() + // must preserve these custom handlers on the cloned object and + // provide compatible clone_obj_with behavior. Zend's default + // clone path currently drops hook dispatch for TypePHP classes. $code .= "typephp_install_property_handlers({$ce}, &{$handlers});\n"; if ($classDef->requireCtor) { $code .= "create_object_{$className} = php::getCreateObjectFn({$ce});\n"; diff --git a/tests/compiler/basic/clone-with-basic.phpt b/tests/compiler/basic/clone-with-basic.phpt new file mode 100644 index 00000000..1d5e42f8 --- /dev/null +++ b/tests/compiler/basic/clone-with-basic.phpt @@ -0,0 +1,98 @@ +--TEST-- +PHP 8.5 clone-with supports one/two arguments and preserves evaluation order +--SKIPIF-- + +--FILE-- +name, "\n"; + $this->revision++; + } +} + +function clone_with_source(CloneWithRecord $source): CloneWithRecord +{ + echo "source\n"; + return $source; +} + +function clone_with_updates(): array +{ + echo "updates\n"; + return ['name' => 'dynamic', 'revision' => 30]; +} + +function main(): void +{ + $source = new CloneWithRecord('original', 1, ['source']); + + $plain = clone($source); + $literal = clone($source, [ + 'name' => 'literal', + 'tags' => ['literal'], + ]); + $dynamic = \clone(clone_with_source($source), clone_with_updates()); + $named = clone(object: $source, withProperties: ['name' => 'named']); + $unpacked = clone(...[ + 'object' => $source, + 'withProperties' => ['name' => 'unpacked'], + ]); + + var_dump($source->name, $source->revision, $source->tags); + var_dump($plain->name, $plain->revision, $plain->tags); + var_dump($literal->name, $literal->revision, $literal->tags); + var_dump($dynamic->name, $dynamic->revision, $dynamic->tags); + var_dump($named->name, $named->revision); + var_dump($unpacked->name, $unpacked->revision); +} +?> +--EXPECT-- +__clone:original +__clone:original +source +updates +__clone:original +__clone:original +__clone:original +string(8) "original" +int(1) +array(1) { + [0]=> + string(6) "source" +} +string(8) "original" +int(2) +array(1) { + [0]=> + string(6) "source" +} +string(7) "literal" +int(2) +array(1) { + [0]=> + string(7) "literal" +} +string(7) "dynamic" +int(30) +array(1) { + [0]=> + string(6) "source" +} +string(5) "named" +int(2) +string(8) "unpacked" +int(2) diff --git a/tests/compiler/basic/clone-with-callable.phpt b/tests/compiler/basic/clone-with-callable.phpt new file mode 100644 index 00000000..75458cdf --- /dev/null +++ b/tests/compiler/basic/clone-with-callable.phpt @@ -0,0 +1,36 @@ +--TEST-- +PHP 8.5 clone is available as a first-class callable and string callable +--SKIPIF-- + +--FILE-- + 8]); + $mapped = array_map('clone', [$source, $first]); + + var_dump($source !== $first, $first->value); + var_dump($mapped[0] !== $source, $mapped[0]->value); + var_dump($mapped[1] !== $first, $mapped[1]->value); +} +?> +--EXPECT-- +bool(true) +int(8) +bool(true) +int(7) +bool(true) +int(8) diff --git a/tests/compiler/basic/clone-with-dynamic-properties.phpt b/tests/compiler/basic/clone-with-dynamic-properties.phpt new file mode 100644 index 00000000..9adc7a01 --- /dev/null +++ b/tests/compiler/basic/clone-with-dynamic-properties.phpt @@ -0,0 +1,36 @@ +--TEST-- +PHP 8.5 clone-with supports dynamic, numeric and object-valued properties +--SKIPIF-- + +--FILE-- +original = 'source'; + + $copy = clone($source, [ + 0 => 'zero', + 'named' => 'value', + 'source' => $source, + ]); + $properties = get_object_vars($copy); + + var_dump($source !== $copy); + var_dump($source->original, $copy->original); + var_dump($properties[0], $properties['named']); + var_dump($copy->source === $source); +} +?> +--EXPECT-- +bool(true) +string(6) "source" +string(6) "source" +string(4) "zero" +string(5) "value" +bool(true) diff --git a/tests/compiler/basic/clone-with-errors.phpt b/tests/compiler/basic/clone-with-errors.phpt new file mode 100644 index 00000000..24128cda --- /dev/null +++ b/tests/compiler/basic/clone-with-errors.phpt @@ -0,0 +1,57 @@ +--TEST-- +PHP 8.5 clone-with stops property updates at the first error +--SKIPIF-- + +--FILE-- + 'first', + 'stop' => 'reject', + 'after' => 'last', + ]); + } catch (RuntimeException $error) { + echo $error->getMessage(), "\n"; + } + + try { + clone($source, ['value' => 'invalid']); + } catch (TypeError $error) { + echo $error::class, ":property\n"; + } + + try { + clone($source, 42); + } catch (TypeError $error) { + echo $error::class, ":argument\n"; + } +} +?> +--EXPECT-- +before:first +stop:reject +rejected reject +TypeError:property +TypeError:argument diff --git a/tests/compiler/basic/clone-with-references.phpt b/tests/compiler/basic/clone-with-references.phpt new file mode 100644 index 00000000..ca54d127 --- /dev/null +++ b/tests/compiler/basic/clone-with-references.phpt @@ -0,0 +1,31 @@ +--TEST-- +PHP 8.5 clone-with rejects active references and unwraps a sole remaining reference +--SKIPIF-- + +--FILE-- + &$value]; + + try { + clone($source, $updates); + } catch (Error $error) { + echo $error->getMessage(), "\n"; + } + + unset($value); + $copy = clone($source, $updates); + var_dump($copy->value); +} +?> +--EXPECT-- +Cannot assign by reference when cloning with updated properties +string(9) "reference" diff --git a/tests/compiler/basic/clone-with-scope-readonly.phpt b/tests/compiler/basic/clone-with-scope-readonly.phpt new file mode 100644 index 00000000..2be1a771 --- /dev/null +++ b/tests/compiler/basic/clone-with-scope-readonly.phpt @@ -0,0 +1,96 @@ +--TEST-- +PHP 8.5 clone-with respects property scope and unlocks readonly properties +--SKIPIF-- + +--XFAIL-- +TypePHP internal classes do not yet preserve private/protected/readonly property scope during clone-with +--FILE-- +readonlyValue = 3; + } + + public function withPrivateAndReadonly(): self + { + return clone($this, [ + 'privateValue' => 10, + 'readonlyValue' => 30, + ]); + } + + public function values(): array + { + return [$this->privateValue, $this->protectedValue, $this->readonlyValue]; + } +} + +class CloneWithScopeChild extends CloneWithScopeBase +{ + public function withProtected(): self + { + return clone($this, ['protectedValue' => 20]); + } +} + +function main(): void +{ + $source = new CloneWithScopeChild(); + $privateCopy = $source->withPrivateAndReadonly(); + $protectedCopy = $source->withProtected(); + + var_dump($source->values()); + var_dump($privateCopy->values()); + var_dump($protectedCopy->values()); + + try { + clone($source, ['protectedValue' => 99]); + } catch (Error $error) { + echo $error->getMessage(), "\n"; + } + + try { + clone($source, ['readonlyValue' => 99]); + } catch (Error $error) { + echo $error->getMessage(), "\n"; + } +} +?> +--EXPECT-- +array(3) { + [0]=> + int(1) + [1]=> + int(2) + [2]=> + int(3) +} +array(3) { + [0]=> + int(10) + [1]=> + int(2) + [2]=> + int(30) +} +array(3) { + [0]=> + int(1) + [1]=> + int(20) + [2]=> + int(3) +} +Cannot access protected property CloneWithScopeChild::$protectedValue +Cannot modify protected(set) readonly property CloneWithScopeBase::$readonlyValue from global scope