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

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
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
Expand Up @@ -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": "dev-feature/clone-with as 11.7.0",
"php" : ">=7.4.0"
},
"require-dev" : {
Expand Down
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
Expand Up @@ -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
Expand Up @@ -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:
Expand Down
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
Expand Up @@ -22,6 +22,7 @@ class PHP74 extends PHP {
OmitConstantTypes,
ReadonlyClasses,
RewriteBlockLambdaExpressions,
RewriteCloneWith,
RewriteEnums,
RewriteExplicitOctals,
RewriteProperties,
Expand Down
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
Expand Up @@ -25,6 +25,7 @@ class PHP80 extends PHP {
OmitConstantTypes,
ReadonlyClasses,
RewriteBlockLambdaExpressions,
RewriteCloneWith,
RewriteDynamicClassConstants,
RewriteEnums,
RewriteExplicitOctals,
Expand Down
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
Expand Up @@ -22,6 +22,8 @@ class PHP81 extends PHP {
use
EmulatePipelines,
RewriteBlockLambdaExpressions,
RewriteCallableClone,
RewriteCloneWith,
RewriteDynamicClassConstants,
RewriteStaticVariableInitializations,
RewriteProperties,
Expand Down
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
Expand Up @@ -22,6 +22,8 @@ class PHP82 extends PHP {
use
EmulatePipelines,
RewriteBlockLambdaExpressions,
RewriteCallableClone,
RewriteCloneWith,
RewriteDynamicClassConstants,
RewriteStaticVariableInitializations,
RewriteProperties,
Expand Down
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
Expand Up @@ -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;

Expand Down
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
Expand Up @@ -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;

Expand Down
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
Expand Up @@ -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;

Expand Down
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;
Expand Down Expand Up @@ -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);
}
}');
}
}
Loading