Skip to content

Implement "clone with" #185

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jun 22, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@
"require" : {
"xp-framework/core": "^12.0 | ^11.6 | ^10.16",
"xp-framework/reflection": "^3.2 | ^2.15",
"xp-framework/ast": "^11.6",
"xp-framework/ast": "^11.7",
"php" : ">=7.4.0"
},
"require-dev" : {
10 changes: 7 additions & 3 deletions src/main/php/lang/ast/emit/CallablesAsClosures.class.php
Original file line number Diff line number Diff line change
@@ -48,8 +48,12 @@ private function emitQuoted($result, $node) {
}

protected function emitCallable($result, $callable) {
$result->out->write('\Closure::fromCallable(');
$this->emitQuoted($result, $callable->expression);
$result->out->write(')');
if ($callable->expression instanceof Literal && 'clone' === $callable->expression->expression) {
$result->out->write('fn($o) => clone $o');
} else {
$result->out->write('\Closure::fromCallable(');
$this->emitQuoted($result, $callable->expression);
$result->out->write(')');
}
}
}
6 changes: 6 additions & 0 deletions src/main/php/lang/ast/emit/PHP.class.php
Original file line number Diff line number Diff line change
@@ -1082,6 +1082,12 @@ protected function emitNewClass($result, $new) {
$result->codegen->leave();
}

protected function emitClone($result, $clone) {
$result->out->write('clone(');
$this->emitArguments($result, $clone->arguments);
$result->out->write(')');
}

protected function emitCallable($result, $callable) {

// Disambiguate the following:
1 change: 1 addition & 0 deletions src/main/php/lang/ast/emit/PHP74.class.php
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@ class PHP74 extends PHP {
OmitConstantTypes,
ReadonlyClasses,
RewriteBlockLambdaExpressions,
RewriteCloneWith,
RewriteEnums,
RewriteExplicitOctals,
RewriteProperties,
1 change: 1 addition & 0 deletions src/main/php/lang/ast/emit/PHP80.class.php
Original file line number Diff line number Diff line change
@@ -25,6 +25,7 @@ class PHP80 extends PHP {
OmitConstantTypes,
ReadonlyClasses,
RewriteBlockLambdaExpressions,
RewriteCloneWith,
RewriteDynamicClassConstants,
RewriteEnums,
RewriteExplicitOctals,
2 changes: 2 additions & 0 deletions src/main/php/lang/ast/emit/PHP81.class.php
Original file line number Diff line number Diff line change
@@ -22,6 +22,8 @@ class PHP81 extends PHP {
use
EmulatePipelines,
RewriteBlockLambdaExpressions,
RewriteCallableClone,
RewriteCloneWith,
RewriteDynamicClassConstants,
RewriteStaticVariableInitializations,
RewriteProperties,
2 changes: 2 additions & 0 deletions src/main/php/lang/ast/emit/PHP82.class.php
Original file line number Diff line number Diff line change
@@ -22,6 +22,8 @@ class PHP82 extends PHP {
use
EmulatePipelines,
RewriteBlockLambdaExpressions,
RewriteCallableClone,
RewriteCloneWith,
RewriteDynamicClassConstants,
RewriteStaticVariableInitializations,
RewriteProperties,
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
@@ -19,7 +19,7 @@
* @see https://wiki.php.net/rfc#php_83
*/
class PHP83 extends PHP {
use EmulatePipelines, RewriteBlockLambdaExpressions, RewriteProperties;
use EmulatePipelines, RewriteCallableClone, RewriteCloneWith, RewriteBlockLambdaExpressions, RewriteProperties;

public $targetVersion= 80300;

2 changes: 1 addition & 1 deletion src/main/php/lang/ast/emit/PHP84.class.php
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@
* @see https://wiki.php.net/rfc#php_84
*/
class PHP84 extends PHP {
use EmulatePipelines, RewriteBlockLambdaExpressions;
use EmulatePipelines, RewriteCallableClone, RewriteCloneWith, RewriteBlockLambdaExpressions;

public $targetVersion= 80400;

2 changes: 1 addition & 1 deletion src/main/php/lang/ast/emit/PHP85.class.php
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@
* @see https://wiki.php.net/rfc#php_85
*/
class PHP85 extends PHP {
use RewriteBlockLambdaExpressions;
use RewriteBlockLambdaExpressions, RewriteCallableClone, RewriteCloneWith; // TODO: Remove once PR is merged!
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the PR in php-src is merged, we can drop these and natively emit clone expressions as function calls with their arguments.


public $targetVersion= 80500;

15 changes: 15 additions & 0 deletions src/main/php/lang/ast/emit/RewriteCallableClone.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php namespace lang\ast\emit;

use lang\ast\nodes\Literal;

/** @see https://wiki.php.net/rfc/clone_with_v2 */
trait RewriteCallableClone {

protected function emitCallable($result, $callable) {
if ($callable->expression instanceof Literal && 'clone' === $callable->expression->expression) {
$result->out->write('fn($o) => clone $o');
} else {
parent::emitCallable($result, $callable);
}
}
}
35 changes: 35 additions & 0 deletions src/main/php/lang/ast/emit/RewriteCloneWith.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php namespace lang\ast\emit;

use lang\ast\nodes\{ArrayLiteral, UnpackExpression};

/** @see https://wiki.php.net/rfc/clone_with_v2 */
trait RewriteCloneWith {

protected function emitClone($result, $clone) {
static $wrapper= '(function($c, array $w) { foreach ($w as $p=>$v) { $c->$p=$v; } return $c;})';

$expr= $clone->arguments['object'] ?? $clone->arguments[0] ?? null;
$with= $clone->arguments['withProperties'] ?? $clone->arguments[1] ?? null;

// Built ontop of a wrapper function which iterates over the property-value pairs,
// assigning them to the clone. Unwind unpack statements, e.g. `clone(...$args)`,
// into an array, manually unpacking it for invocation.
if ($expr instanceof UnpackExpression || $with instanceof UnpackExpression) {
$t= $result->temp();
$result->out->write('('.$t.'=');
$this->emitOne($result, new ArrayLiteral($with ? [[null, $expr], [null, $with]] : [[null, $expr]], $clone->line));
$result->out->write(')?');
$result->out->write($wrapper.'(clone ('.$t.'["object"] ?? '.$t.'[0]), '.$t.'["withProperties"] ?? '.$t.'[1] ?? [])');
$result->out->write(':null');
} else if ($with) {
$result->out->write($wrapper.'(clone ');
$this->emitOne($result, $expr);
$result->out->write(',');
$this->emitOne($result, $with);
$result->out->write(')');
} else {
$result->out->write('clone ');
$this->emitOne($result, $expr);
}
}
}
160 changes: 158 additions & 2 deletions src/test/php/lang/ast/unittest/emit/CloningTest.class.php
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
<?php namespace lang\ast\unittest\emit;

use test\{Assert, Before, Test};
use lang\Error;
use test\verify\Runtime;
use test\{Assert, Before, Expect, Ignore, Test, Values};

/** @see https://www.php.net/manual/en/language.oop5.cloning.php */
class CloningTest extends EmittingTest {
private $fixture;

/** @return iterable */
private function arguments() {
yield ['clone($in, ["id" => $this->id, "name" => "Changed"])'];
yield ['clone($in, withProperties: ["id" => $this->id, "name" => "Changed"])'];
yield ['clone(object: $in, withProperties: ["id" => $this->id, "name" => "Changed"])'];
yield ['clone(withProperties: ["id" => $this->id, "name" => "Changed"], object: $in)'];
}

#[Before]
public function fixture() {
$this->fixture= new class() {
public $id= 1;
public $name= 'Test';

public function toString() {
return "<id: {$this->id}, name: {$this->name}>";
}

public function with($id) {
$this->id= $id;
@@ -52,6 +67,147 @@ public function run($in) {
}
}', $this->fixture->with(1));

Assert::equals([1, 2], [$this->fixture->id, $clone->id]);
Assert::equals(
['<id: 1, name: Test>', '<id: 2, name: Test>'],
[$this->fixture->toString(), $clone->toString()]
);
}

#[Test, Values(from: 'arguments')]
public function clone_with($expression) {
$clone= $this->run('class %T {
private $id= 6100;
public function run($in) { return '.$expression.'; }
}', $this->fixture->with(1));

Assert::equals(
['<id: 1, name: Test>', '<id: 6100, name: Changed>'],
[$this->fixture->toString(), $clone->toString()]
);
}

#[Test]
public function clone_unpack() {
$clone= $this->run('class %T {
public function run($in) {
return clone(...["object" => $in]);
}
}', $this->fixture);

Assert::equals('<id: 2, name: Test>', $clone->toString());
}

#[Test]
public function clone_unpack_with_properties() {
$clone= $this->run('class %T {
public function run($in) {
return clone(...["object" => $in, "withProperties" => ["name" => "Changed"]]);
}
}', $this->fixture);

Assert::equals('<id: 2, name: Changed>', $clone->toString());
}

#[Test]
public function clone_unpack_object_and_properties() {
$clone= $this->run('class %T {
public function run($in) {
return clone(...["object" => $in], ...["withProperties" => ["name" => "Changed"]]);
}
}', $this->fixture);

Assert::equals('<id: 2, name: Changed>', $clone->toString());
}

#[Test]
public function clone_unpack_only_properties() {
$clone= $this->run('class %T {
public function run($in) {
return clone($in, ...["withProperties" => ["name" => "Changed"]]);
}
}', $this->fixture);

Assert::equals('<id: 2, name: Changed>', $clone->toString());
}

#[Test]
public function clone_with_named_argument() {
$clone= $this->run('class %T {
public function run($in) {
return clone(object: $in);
}
}', $this->fixture->with(1));

Assert::equals(
['<id: 1, name: Test>', '<id: 2, name: Test>'],
[$this->fixture->toString(), $clone->toString()]
);
}

#[Test, Values(['protected', 'private'])]
public function clone_with_can_access($modifiers) {
$clone= $this->run('class %T {
'.$modifiers.' $id= 1;

public function id() { return $this->id; }

public function run() {
return clone($this, ["id" => 6100]);
}
}');

Assert::equals(6100, $clone->id());
}

#[Test, Ignore('Could be done with reflection but with significant performance cost')]
public function clone_with_respects_visibility() {
$base= $this->type('class %T { private $id= 1; }');

Assert::throws(Error::class, fn() => $this->run('class %T extends '.$base.' {
public function run() {
clone($this, ["id" => 6100]); // Tries to set private member from base
}
}'));
}

#[Test]
public function clone_callable() {
$clone= $this->run('class %T {
public function run($in) {
return array_map(clone(...), [$in])[0];
}
}', $this->fixture);

Assert::true($clone instanceof $this->fixture && $this->fixture !== $clone);
}

#[Test, Values(['"clone"', '$func']), Runtime(php: '>=8.5.0')]
public function clone_callable_reference($expression) {
$clone= $this->run('class %T {
public function run($in) {
$func= "clone";
return array_map('.$expression.', [$in])[0];
}
}', $this->fixture);

Assert::true($clone instanceof $this->fixture && $this->fixture !== $clone);
}

#[Test, Expect(Error::class)]
public function clone_null_object() {
$this->run('class %T {
public function run() {
return clone(null);
}
}');
}

#[Test, Expect(Error::class)]
public function clone_with_null_properties() {
$this->run('class %T {
public function run() {
return clone($this, null);
}
}');
}
}