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
master
韩天峰 1 day ago
parent 9fc5755544
commit 795a8e0a1d
  1. 1
      docs/INCOMPATIBLE_PHP_FEATURES.md
  2. 2
      docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md
  3. 16
      phpunit/code/clone-with-codegen.php
  4. 62
      phpunit/src/CloneWithCodegenTest.php
  5. 7
      src/CompilerBase.php
  6. 20
      src/Parser/FunctionCallTrait.php
  7. 4
      src/Translator.php
  8. 98
      tests/compiler/basic/clone-with-basic.phpt
  9. 36
      tests/compiler/basic/clone-with-callable.phpt
  10. 36
      tests/compiler/basic/clone-with-dynamic-properties.phpt
  11. 57
      tests/compiler/basic/clone-with-errors.phpt
  12. 31
      tests/compiler/basic/clone-with-references.phpt
  13. 96
      tests/compiler/basic/clone-with-scope-readonly.phpt

@ -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 执行同等作用域检查。

@ -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. |

@ -0,0 +1,16 @@
<?php
class CloneWithCodegen
{
private int $value = 1;
public function copy(): self
{
return clone($this, ['value' => 2]);
}
}
function clone_with_global(CloneWithCodegen $value): CloneWithCodegen
{
return clone($value, []);
}

@ -0,0 +1,62 @@
<?php
/**
* This file is part of Swoole-Compiler(AOT).
*
* @link https://www.swoole.com/
* @contact service@swoole.com
*/
use TypePhp\CompilerTest;
/**
* @internal
* @coversNothing
*/
final class CloneWithCodegenTest extends BaseTest
{
public function testMethodCloneWithPassesLexicalScopeToZend(): void
{
$code = $this->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);
}
}

@ -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(

@ -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);
}

@ -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";

@ -0,0 +1,98 @@
--TEST--
PHP 8.5 clone-with supports one/two arguments and preserves evaluation order
--SKIPIF--
<?php
if (PHP_VERSION_ID < 80500) {
die('skip requires PHP 8.5');
}
?>
--FILE--
<?php
class CloneWithRecord
{
public function __construct(
public string $name,
public int $revision,
public array $tags,
) {}
public function __clone(): void
{
echo "__clone:", $this->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)

@ -0,0 +1,36 @@
--TEST--
PHP 8.5 clone is available as a first-class callable and string callable
--SKIPIF--
<?php
if (PHP_VERSION_ID < 80500) {
die('skip requires PHP 8.5');
}
?>
--FILE--
<?php
class CloneCallableValue
{
public function __construct(public int $value) {}
}
function main(): void
{
$source = new CloneCallableValue(7);
$callable = clone(...);
$first = $callable($source, ['value' => 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)

@ -0,0 +1,36 @@
--TEST--
PHP 8.5 clone-with supports dynamic, numeric and object-valued properties
--SKIPIF--
<?php
if (PHP_VERSION_ID < 80500) {
die('skip requires PHP 8.5');
}
?>
--FILE--
<?php
function main(): void
{
$source = new stdClass();
$source->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)

@ -0,0 +1,57 @@
--TEST--
PHP 8.5 clone-with stops property updates at the first error
--SKIPIF--
<?php
if (PHP_VERSION_ID < 80500) {
die('skip requires PHP 8.5');
}
?>
--FILE--
<?php
class CloneWithErrors
{
public int $value = 1;
public function __set(string $name, mixed $value): void
{
echo $name, ':', $value, "\n";
if ($name === 'stop') {
throw new RuntimeException('rejected ' . $value);
}
}
}
function main(): void
{
$source = new CloneWithErrors();
try {
clone($source, [
'before' => '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

@ -0,0 +1,31 @@
--TEST--
PHP 8.5 clone-with rejects active references and unwraps a sole remaining reference
--SKIPIF--
<?php
if (PHP_VERSION_ID < 80500) {
die('skip requires PHP 8.5');
}
?>
--FILE--
<?php
function main(): void
{
$source = new stdClass();
$value = 'reference';
$updates = ['value' => &$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"

@ -0,0 +1,96 @@
--TEST--
PHP 8.5 clone-with respects property scope and unlocks readonly properties
--SKIPIF--
<?php
if (PHP_VERSION_ID < 80500) {
die('skip requires PHP 8.5');
}
?>
--XFAIL--
TypePHP internal classes do not yet preserve private/protected/readonly property scope during clone-with
--FILE--
<?php
class CloneWithScopeBase
{
private int $privateValue = 1;
protected int $protectedValue = 2;
public readonly int $readonlyValue;
public function __construct()
{
$this->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
Loading…
Cancel
Save