From 1a5e7e1bdd7ca9bf64bf396096c22ed08c1ae5b1 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Wed, 27 Mar 2024 13:32:06 +0100 Subject: [PATCH 01/15] Add support for pipelines with `|>` and `?|>` --- src/main/php/lang/ast/emit/PHP.class.php | 40 ++++++++++ .../ast/unittest/emit/PipelinesTest.class.php | 75 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100755 src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php diff --git a/src/main/php/lang/ast/emit/PHP.class.php b/src/main/php/lang/ast/emit/PHP.class.php index ac3d93a4..d86cdb7c 100755 --- a/src/main/php/lang/ast/emit/PHP.class.php +++ b/src/main/php/lang/ast/emit/PHP.class.php @@ -7,6 +7,8 @@ ArrayLiteral, BinaryExpression, Block, + CallableExpression, + CallableNewExpression, Comment, Expression, InstanceExpression, @@ -1129,6 +1131,44 @@ protected function emitNullsafeInstance($result, $instance) { $this->emitOne($result, $instance->member); } + protected function emitPipeTarget($result, $pipe, $argument) { + + // $expr |> new T(...) => new T($expr) + if ($pipe->target instanceof CallableNewExpression) { + $pipe->target->type->arguments= [$argument]; + $this->emitOne($result, $pipe->target->type); + $pipe->target->type->arguments= null; + return; + } + + // $expr |> strtoupper(...) => strtoupper($expr) + // $expr |> fn($x) => $x * 2 => (fn($x) => $x * 2)($expr) + if ($pipe->target instanceof CallableExpression) { + $this->emitOne($result, $pipe->target->expression); + } else { + $result->out->write('('); + $this->emitOne($result, $pipe->target); + $result->out->write(')'); + } + + $result->out->write('('); + $this->emitOne($result, $argument); + $result->out->write(')'); + } + + protected function emitPipe($result, $pipe) { + $this->emitPipeTarget($result, $pipe, $pipe->expression); + } + + protected function emitNullsafePipe($result, $pipe) { + $t= $result->temp(); + $result->out->write('null===('.$t.'='); + $this->emitOne($result, $pipe->expression); + $result->out->write(')?null:'); + + $this->emitPipeTarget($result, $pipe, new Variable(substr($t, 1))); + } + protected function emitUnpack($result, $unpack) { $result->out->write('...'); $this->emitOne($result, $unpack->expression); diff --git a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php new file mode 100755 index 00000000..7fa46a9f --- /dev/null +++ b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php @@ -0,0 +1,75 @@ +run('class %T { + public function run() { + return "test" |> strtoupper(...); + } + }'); + + Assert::equals('TEST', $r); + } + + #[Test] + public function pipe_to_callable_new() { + $r= $this->run('class %T { + public function run() { + return "2024-03-27" |> new \util\Date(...); + } + }'); + + Assert::equals('2024-03-27', $r->toString('Y-m-d')); + } + + #[Test] + public function pipe_to_callable_anonymous_new() { + $r= $this->run('class %T { + public function run() { + return "2024-03-27" |> new class(...) { + public function __construct(public string $value) { } + }; + } + }'); + + Assert::equals('2024-03-27', $r->value); + } + + #[Test] + public function pipe_to_closure() { + $r= $this->run('class %T { + public function run() { + return "test" |> fn($x) => $x.": OK"; + } + }'); + + Assert::equals('test: OK', $r); + } + + #[Test] + public function pipe_chain() { + $r= $this->run('class %T { + public function run() { + return " test " |> trim(...) |> strtoupper(...); + } + }'); + + Assert::equals('TEST', $r); + } + + #[Test, Values([[null, null], ['test', 'TEST'], [' test ', 'TEST']])] + public function nullsafe_pipe($input, $expected) { + $r= $this->run('class %T { + public function run($arg) { + return $arg ?|> trim(...) ?|> strtoupper(...); + } + }', $input); + + Assert::equals($expected, $r); + } +} \ No newline at end of file From 196dfb9d055d2262e143045564d6aea1f83f9969 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Wed, 27 Mar 2024 13:37:47 +0100 Subject: [PATCH 02/15] QA: Remove unused imports --- src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php index 7fa46a9f..cff7cd90 100755 --- a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php @@ -1,7 +1,6 @@ Date: Wed, 27 Mar 2024 13:42:10 +0100 Subject: [PATCH 03/15] Add test ensuring the expression is only invoked once --- .../lang/ast/unittest/emit/PipelinesTest.class.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php index cff7cd90..0b10220f 100755 --- a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php @@ -61,8 +61,19 @@ public function run() { Assert::equals('TEST', $r); } - #[Test, Values([[null, null], ['test', 'TEST'], [' test ', 'TEST']])] + #[Test, Values([[['test'], 'TEST'], [[], null]])] public function nullsafe_pipe($input, $expected) { + $r= $this->run('class %T { + public function run($arg) { + return array_shift($arg) ?|> strtoupper(...); + } + }', $input); + + Assert::equals($expected, $r); + } + + #[Test, Values([[null, null], ['test', 'TEST'], [' test ', 'TEST']])] + public function nullsafe_chain($input, $expected) { $r= $this->run('class %T { public function run($arg) { return $arg ?|> trim(...) ?|> strtoupper(...); From 725e453642f22c9d744cf430fbf9da969c0ecc1c Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Wed, 27 Mar 2024 14:13:16 +0100 Subject: [PATCH 04/15] Add tests for a variety of callables --- .../ast/unittest/emit/PipelinesTest.class.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php index 0b10220f..8e5dbeec 100755 --- a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php @@ -15,6 +15,53 @@ public function run() { Assert::equals('TEST', $r); } + #[Test] + public function pipe_to_variable() { + $r= $this->run('class %T { + public function run() { + $f= strtoupper(...); + return "test" |> $f; + } + }'); + + Assert::equals('TEST', $r); + } + + #[Test] + public function pipe_to_callable_string() { + $r= $this->run('class %T { + public function run() { + return "test" |> "strtoupper"; + } + }'); + + Assert::equals('TEST', $r); + } + + #[Test] + public function pipe_to_callable_array() { + $r= $this->run('class %T { + public function toUpper($x) { return strtoupper($x); } + + public function run() { + return "test" |> [$this, "toUpper"]; + } + }'); + + Assert::equals('TEST', $r); + } + + #[Test] + public function pipe_to_callable_without_all_args() { + $r= $this->run('class %T { + public function run() { + return "A&B" |> htmlspecialchars(...); + } + }'); + + Assert::equals('A&B', $r); + } + #[Test] public function pipe_to_callable_new() { $r= $this->run('class %T { From 00d9658885f6b2e645eea1ca8571aa134e59c1c4 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Wed, 27 Mar 2024 15:09:38 +0100 Subject: [PATCH 05/15] Fix xp-framework/ast dependency --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 303b7c68..af9cf190 100755 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "require" : { "xp-framework/core": "^12.0 | ^11.6 | ^10.16", "xp-framework/reflection": "^3.0 | ^2.13", - "xp-framework/ast": "^11.0 | ^10.1", + "xp-framework/ast": "dev-feature/pipelines as 11.1.0", "php" : ">=7.4.0" }, "require-dev" : { From ff28975cf0a3c69756df61ef818e8f916c4092ed Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 8 Feb 2025 09:59:48 +0100 Subject: [PATCH 06/15] Link to https://wiki.php.net/rfc/pipe-operator-v3 --- .../ast/unittest/emit/PipelinesTest.class.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php index 8e5dbeec..fdcb1231 100755 --- a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php @@ -2,6 +2,7 @@ use test\{Assert, Test, Values}; +/** @see https://wiki.php.net/rfc/pipe-operator-v3 */ class PipelinesTest extends EmittingTest { #[Test] @@ -129,4 +130,19 @@ public function run($arg) { Assert::equals($expected, $r); } + + #[Test] + public function rfc_example() { + $r= $this->run('class %T { + public function run() { + return "Hello World" + |> "htmlentities" + |> str_split(...) + |> fn($x) => array_map(strtoupper(...), $x) + |> fn($x) => array_filter($x, fn($v) => $v != "O") + ; + } + }'); + Assert::equals(['H', 'E', 'L', 'L', ' ', 'W', 'R', 'L', 'D'], array_values($r)); + } } \ No newline at end of file From a7c7d79949f628d15cfe1cb35d7cbaf4c30a0616 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 8 Feb 2025 10:02:11 +0100 Subject: [PATCH 07/15] Test precedence, see https://wiki.php.net/rfc/pipe-operator-v3#precedence --- .../lang/ast/unittest/emit/PipelinesTest.class.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php index fdcb1231..b18915a0 100755 --- a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php @@ -131,6 +131,17 @@ public function run($arg) { Assert::equals($expected, $r); } + #[Test] + public function precedence() { + $r= $this->run('class %T { + public function run() { + return "te"."st" |> strtoupper(...); + } + }'); + + Assert::equals('TEST', $r); + } + #[Test] public function rfc_example() { $r= $this->run('class %T { From 4357341f0cb254d8ff06bad5c05fbf576f9a6d8f Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 18 May 2025 11:22:05 +0200 Subject: [PATCH 08/15] Fix pipeline execution order --- src/main/php/lang/ast/emit/PHP.class.php | 44 +++-- .../ast/unittest/emit/PipelinesTest.class.php | 166 +++++++++++++++++- 2 files changed, 183 insertions(+), 27 deletions(-) diff --git a/src/main/php/lang/ast/emit/PHP.class.php b/src/main/php/lang/ast/emit/PHP.class.php index 01e3e15b..fc7256a4 100755 --- a/src/main/php/lang/ast/emit/PHP.class.php +++ b/src/main/php/lang/ast/emit/PHP.class.php @@ -1158,42 +1158,40 @@ protected function emitNullsafeInstance($result, $instance) { $this->emitOne($result, $instance->member); } - protected function emitPipeTarget($result, $pipe, $argument) { - - // $expr |> new T(...) => new T($expr) - if ($pipe->target instanceof CallableNewExpression) { - $pipe->target->type->arguments= [$argument]; - $this->emitOne($result, $pipe->target->type); - $pipe->target->type->arguments= null; - return; - } - - // $expr |> strtoupper(...) => strtoupper($expr) - // $expr |> fn($x) => $x * 2 => (fn($x) => $x * 2)($expr) - if ($pipe->target instanceof CallableExpression) { - $this->emitOne($result, $pipe->target->expression); + protected function emitPipeTarget($result, $target, $arg) { + if ($target instanceof CallableNewExpression) { + $target->type->arguments= [new Variable(substr($arg, 1))]; + $this->emitOne($result, $target->type); + $target->type->arguments= null; + } else if ($target instanceof CallableExpression) { + $this->emitOne($result, $target->expression); + $result->out->write('('.$arg.')'); } else { $result->out->write('('); - $this->emitOne($result, $pipe->target); - $result->out->write(')'); + $this->emitOne($result, $target); + $result->out->write(')('.$arg.')'); } - - $result->out->write('('); - $this->emitOne($result, $argument); - $result->out->write(')'); } protected function emitPipe($result, $pipe) { - $this->emitPipeTarget($result, $pipe, $pipe->expression); + + // $expr |> strtoupper(...) => [$arg= $expr, strtoupper($arg)][1] + $t= $result->temp(); + $result->out->write('['.$t.'='); + $this->emitOne($result, $pipe->expression); + $result->out->write(','); + $this->emitPipeTarget($result, $pipe->target, $t); + $result->out->write('][1]'); } protected function emitNullsafePipe($result, $pipe) { + + // $expr ?|> strtoupper(...) => null === ($arg= $expr) ? null : strtoupper($arg) $t= $result->temp(); $result->out->write('null===('.$t.'='); $this->emitOne($result, $pipe->expression); $result->out->write(')?null:'); - - $this->emitPipeTarget($result, $pipe, new Variable(substr($t, 1))); + $this->emitPipeTarget($result, $pipe->target, $t); } protected function emitUnpack($result, $unpack) { diff --git a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php index b18915a0..43bfc290 100755 --- a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php @@ -1,6 +1,8 @@ run('use lang\Error; class %T { + public function run() { + return "test" |> throw new Error("Test"); + } + }'); + } + + #[Test, Expect(Error::class)] + public function pipe_to_missing() { + $this->run('class %T { + public function run() { + return "test" |> "__missing"; + } + }'); + } + #[Test] public function pipe_chain() { $r= $this->run('class %T { @@ -109,7 +129,7 @@ public function run() { Assert::equals('TEST', $r); } - #[Test, Values([[['test'], 'TEST'], [[], null]])] + #[Test, Values([[['test'], 'TEST'], [[''], ''], [[], null]])] public function nullsafe_pipe($input, $expected) { $r= $this->run('class %T { public function run($arg) { @@ -120,7 +140,7 @@ public function run($arg) { Assert::equals($expected, $r); } - #[Test, Values([[null, null], ['test', 'TEST'], [' test ', 'TEST']])] + #[Test, Values([[null, null], ['', ''], ['test', 'TEST'], [' test ', 'TEST']])] public function nullsafe_chain($input, $expected) { $r= $this->run('class %T { public function run($arg) { @@ -132,7 +152,7 @@ public function run($arg) { } #[Test] - public function precedence() { + public function concat_precedence() { $r= $this->run('class %T { public function run() { return "te"."st" |> strtoupper(...); @@ -142,6 +162,54 @@ public function run() { Assert::equals('TEST', $r); } + #[Test] + public function addition_precedence() { + $r= $this->run('class %T { + public function run() { + return 5 + 2 |> fn($i) => $i * 2; + } + }'); + + Assert::equals(14, $r); + } + + #[Test] + public function comparison_precedence() { + $r= $this->run('class %T { + public function run() { + return 5 |> fn($i) => $i * 2 === 10; + } + }'); + + Assert::true($r); + } + + #[Test, Values([[0, 'even'], [1, 'odd'], [2, 'even']])] + public function ternary_precedence($arg, $expected) { + $r= $this->run('class %T { + public function run($arg) { + return $arg |> fn($i) => $i % 2 ? "odd" : "even"; + } + }', $arg); + + Assert::equals($expected, $r); + } + + #[Test, Values([[0, 'root'], [1001, 'test'], [1002, '#unknown']])] + public function coalesce_precedence($arg, $expected) { + $r= $this->run('class %T { + private $users= [0 => "root", 1001 => "test"]; + + private function user($id) { return $this->users[$id] ?? null; } + + public function run($arg) { + return $arg |> $this->user(...) ?? "#unknown"; + } + }', $arg); + + Assert::equals($expected, $r); + } + #[Test] public function rfc_example() { $r= $this->run('class %T { @@ -156,4 +224,94 @@ public function run() { }'); Assert::equals(['H', 'E', 'L', 'L', ' ', 'W', 'R', 'L', 'D'], array_values($r)); } + + #[Test, Expect(Error::class), Runtime(php: '>=8.5.0')] + public function rejects_by_reference_functions() { + $this->run('class %T { + private function modify(&$arg) { $arg++; } + + public function run() { + $val= 1; + return $val |> $this->modify(...); + } + }'); + } + + #[Test] + public function accepts_prefer_by_reference_functions() { + $r= $this->run('class %T { + public function run() { + return ["hello", "world"] |> array_multisort(...); + } + }'); + + Assert::true($r); + } + + #[Test] + public function execution_order() { + $r= $this->run('class %T { + public function run() { + $invoked= []; + + $first= function() use(&$invoked) { $invoked[]= "first"; return 1; }; + $second= function() use(&$invoked) { $invoked[]= "second"; return false; }; + $skipped= function() use(&$invoked) { $invoked[]= "skipped"; return $in; }; + $third= function($in) use(&$invoked) { $invoked[]= "third"; return $in; }; + $capture= function($result) use(&$invoked) { $invoked[]= $result; }; + + $first() |> ($second() ? $skipped : $third) |> $capture; + return $invoked; + } + }'); + + Assert::equals(['first', 'second', 'third', 1], $r); + } + + #[Test] + public function interrupted_by_exception() { + $r= $this->run('use lang\Error; class %T { + public function run() { + $invoked= []; + + $provide= function() use(&$invoked) { $invoked[]= "provide"; return 1; }; + $transform= function($in) use(&$invoked) { $invoked[]= "transform"; return $in * 2; }; + $throw= function() use(&$invoked) { $invoked[]= "throw"; throw new Error("Break"); }; + + try { + $provide() |> $transform |> $throw |> throw new Error("Unreachable"); + } catch (Error $e) { + $invoked[]= $e->compoundMessage(); + } + return $invoked; + } + }'); + + Assert::equals(['provide', 'transform', 'throw', 'Exception lang.Error (Break)'], $r); + } + + #[Test] + public function generators() { + $r= $this->run('class %T { + private function range($lo, $hi) { + for ($i= $lo; $i <= $hi; $i++) { + yield $i; + } + } + + private function map($fn) { + return function($it) use($fn) { + foreach ($it as $element) { + yield $fn($element); + } + }; + } + + public function run() { + return $this->range(1, 3) |> $this->map(fn($e) => $e + 1) |> iterator_to_array(...); + } + }'); + + Assert::equals([2, 3, 4], $r); + } } \ No newline at end of file From e05b5b13638323b1eba3a57e088a2b24b421f40c Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 18 May 2025 11:54:00 +0200 Subject: [PATCH 09/15] Extract pipeline emulation into trait Once php/php-src#17118 is merged, we can create a PHP 8.5 emitter which does not include it and emits pipes natively --- .../lang/ast/emit/EmulatePipelines.class.php | 48 +++++++++++++++++++ src/main/php/lang/ast/emit/PHP.class.php | 30 ++---------- src/main/php/lang/ast/emit/PHP74.class.php | 1 + src/main/php/lang/ast/emit/PHP80.class.php | 1 + src/main/php/lang/ast/emit/PHP81.class.php | 1 + src/main/php/lang/ast/emit/PHP82.class.php | 1 + src/main/php/lang/ast/emit/PHP83.class.php | 2 +- src/main/php/lang/ast/emit/PHP84.class.php | 2 +- 8 files changed, 59 insertions(+), 27 deletions(-) create mode 100755 src/main/php/lang/ast/emit/EmulatePipelines.class.php diff --git a/src/main/php/lang/ast/emit/EmulatePipelines.class.php b/src/main/php/lang/ast/emit/EmulatePipelines.class.php new file mode 100755 index 00000000..d101605c --- /dev/null +++ b/src/main/php/lang/ast/emit/EmulatePipelines.class.php @@ -0,0 +1,48 @@ +type->arguments= [new Variable(substr($arg, 1))]; + $this->emitOne($result, $target->type); + $target->type->arguments= null; + } else if ($target instanceof CallableExpression) { + $this->emitOne($result, $target->expression); + $result->out->write('('.$arg.')'); + } else { + $result->out->write('('); + $this->emitOne($result, $target); + $result->out->write(')('.$arg.')'); + } + } + + protected function emitPipe($result, $pipe) { + + // $expr |> strtoupper(...) => [$arg= $expr, strtoupper($arg)][1] + $t= $result->temp(); + $result->out->write('['.$t.'='); + $this->emitOne($result, $pipe->expression); + $result->out->write(','); + $this->emitPipeTarget($result, $pipe->target, $t); + $result->out->write('][1]'); + } + + protected function emitNullsafePipe($result, $pipe) { + + // $expr ?|> strtoupper(...) => null === ($arg= $expr) ? null : strtoupper($arg) + $t= $result->temp(); + $result->out->write('null===('.$t.'='); + $this->emitOne($result, $pipe->expression); + $result->out->write(')?null:'); + $this->emitPipeTarget($result, $pipe->target, $t); + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/emit/PHP.class.php b/src/main/php/lang/ast/emit/PHP.class.php index fc7256a4..584769e3 100755 --- a/src/main/php/lang/ast/emit/PHP.class.php +++ b/src/main/php/lang/ast/emit/PHP.class.php @@ -1158,40 +1158,20 @@ protected function emitNullsafeInstance($result, $instance) { $this->emitOne($result, $instance->member); } - protected function emitPipeTarget($result, $target, $arg) { - if ($target instanceof CallableNewExpression) { - $target->type->arguments= [new Variable(substr($arg, 1))]; - $this->emitOne($result, $target->type); - $target->type->arguments= null; - } else if ($target instanceof CallableExpression) { - $this->emitOne($result, $target->expression); - $result->out->write('('.$arg.')'); - } else { - $result->out->write('('); - $this->emitOne($result, $target); - $result->out->write(')('.$arg.')'); - } - } - protected function emitPipe($result, $pipe) { - - // $expr |> strtoupper(...) => [$arg= $expr, strtoupper($arg)][1] - $t= $result->temp(); - $result->out->write('['.$t.'='); $this->emitOne($result, $pipe->expression); - $result->out->write(','); - $this->emitPipeTarget($result, $pipe->target, $t); - $result->out->write('][1]'); + $result->out->write('|>'); + $this->emitOne($result, $pipe->target); } protected function emitNullsafePipe($result, $pipe) { - // $expr ?|> strtoupper(...) => null === ($arg= $expr) ? null : strtoupper($arg) + // $expr ?|> strtoupper(...) => null === ($t= $expr) ? null : $t |> strtoupper(...) $t= $result->temp(); $result->out->write('null===('.$t.'='); $this->emitOne($result, $pipe->expression); - $result->out->write(')?null:'); - $this->emitPipeTarget($result, $pipe->target, $t); + $result->out->write(')?null:'.$t.'|>'); + $this->emitOne($result, $pipe->target); } protected function emitUnpack($result, $unpack) { diff --git a/src/main/php/lang/ast/emit/PHP74.class.php b/src/main/php/lang/ast/emit/PHP74.class.php index 6f97959c..d8feaec8 100755 --- a/src/main/php/lang/ast/emit/PHP74.class.php +++ b/src/main/php/lang/ast/emit/PHP74.class.php @@ -14,6 +14,7 @@ class PHP74 extends PHP { AttributesAsComments, CallablesAsClosures, ChainScopeOperators, + EmulatePipelines, MatchAsTernaries, NonCapturingCatchVariables, NullsafeAsTernaries, diff --git a/src/main/php/lang/ast/emit/PHP80.class.php b/src/main/php/lang/ast/emit/PHP80.class.php index db83e745..02c67c29 100755 --- a/src/main/php/lang/ast/emit/PHP80.class.php +++ b/src/main/php/lang/ast/emit/PHP80.class.php @@ -21,6 +21,7 @@ class PHP80 extends PHP { use ArrayUnpackUsingMerge, CallablesAsClosures, + EmulatePipelines, OmitConstantTypes, ReadonlyClasses, RewriteBlockLambdaExpressions, diff --git a/src/main/php/lang/ast/emit/PHP81.class.php b/src/main/php/lang/ast/emit/PHP81.class.php index f93b4969..f0062593 100755 --- a/src/main/php/lang/ast/emit/PHP81.class.php +++ b/src/main/php/lang/ast/emit/PHP81.class.php @@ -20,6 +20,7 @@ */ class PHP81 extends PHP { use + EmulatePipelines, RewriteBlockLambdaExpressions, RewriteDynamicClassConstants, RewriteStaticVariableInitializations, diff --git a/src/main/php/lang/ast/emit/PHP82.class.php b/src/main/php/lang/ast/emit/PHP82.class.php index 89fa9cbd..c8da1649 100755 --- a/src/main/php/lang/ast/emit/PHP82.class.php +++ b/src/main/php/lang/ast/emit/PHP82.class.php @@ -20,6 +20,7 @@ */ class PHP82 extends PHP { use + EmulatePipelines, RewriteBlockLambdaExpressions, RewriteDynamicClassConstants, RewriteStaticVariableInitializations, diff --git a/src/main/php/lang/ast/emit/PHP83.class.php b/src/main/php/lang/ast/emit/PHP83.class.php index 7c7293b2..06879d88 100755 --- a/src/main/php/lang/ast/emit/PHP83.class.php +++ b/src/main/php/lang/ast/emit/PHP83.class.php @@ -18,7 +18,7 @@ * @see https://wiki.php.net/rfc#php_83 */ class PHP83 extends PHP { - use RewriteBlockLambdaExpressions, RewriteProperties; + use EmulatePipelines, RewriteBlockLambdaExpressions, RewriteProperties; public $targetVersion= 80300; diff --git a/src/main/php/lang/ast/emit/PHP84.class.php b/src/main/php/lang/ast/emit/PHP84.class.php index 71379f30..a9083a8f 100755 --- a/src/main/php/lang/ast/emit/PHP84.class.php +++ b/src/main/php/lang/ast/emit/PHP84.class.php @@ -18,7 +18,7 @@ * @see https://wiki.php.net/rfc#php_84 */ class PHP84 extends PHP { - use RewriteBlockLambdaExpressions; + use EmulatePipelines, RewriteBlockLambdaExpressions; public $targetVersion= 80400; From 1c5583f420720d73f0ace3f411d58ac8217e36b3 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 18 May 2025 11:55:49 +0200 Subject: [PATCH 10/15] QA: Remove unused imports --- src/main/php/lang/ast/emit/PHP.class.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/php/lang/ast/emit/PHP.class.php b/src/main/php/lang/ast/emit/PHP.class.php index 584769e3..56d0be09 100755 --- a/src/main/php/lang/ast/emit/PHP.class.php +++ b/src/main/php/lang/ast/emit/PHP.class.php @@ -7,8 +7,6 @@ ArrayLiteral, BinaryExpression, Block, - CallableExpression, - CallableNewExpression, Comment, Expression, InstanceExpression, From 93e033fdb74940312e4b7ae16df9fa17ab204105 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 18 May 2025 11:55:49 +0200 Subject: [PATCH 11/15] QA: Document null-safe pipe operator [skip ci] --- src/main/php/lang/ast/emit/EmulatePipelines.class.php | 5 +++-- src/main/php/lang/ast/emit/PHP.class.php | 2 -- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/php/lang/ast/emit/EmulatePipelines.class.php b/src/main/php/lang/ast/emit/EmulatePipelines.class.php index d101605c..828f3829 100755 --- a/src/main/php/lang/ast/emit/EmulatePipelines.class.php +++ b/src/main/php/lang/ast/emit/EmulatePipelines.class.php @@ -3,9 +3,10 @@ use lang\ast\nodes\{CallableExpression, CallableNewExpression, Variable}; /** - * Emulates pipelines + * Emulates pipelines / the pipe operator, including a null-safe version. * - * @see https://wiki.php.net/rfc/pipe-operator-v3#precedence + * @see https://wiki.php.net/rfc/pipe-operator-v3 + * @see https://externals.io/message/107661#107670 * @test lang.ast.unittest.emit.PipelinesTest */ trait EmulatePipelines { diff --git a/src/main/php/lang/ast/emit/PHP.class.php b/src/main/php/lang/ast/emit/PHP.class.php index 584769e3..56d0be09 100755 --- a/src/main/php/lang/ast/emit/PHP.class.php +++ b/src/main/php/lang/ast/emit/PHP.class.php @@ -7,8 +7,6 @@ ArrayLiteral, BinaryExpression, Block, - CallableExpression, - CallableNewExpression, Comment, Expression, InstanceExpression, From 55fc0bd12b93bbd529c1724273e396c738d58f87 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 18 May 2025 13:14:15 +0200 Subject: [PATCH 12/15] Add more tests for operator precedence --- .../ast/unittest/emit/PipelinesTest.class.php | 27 +++++++++- .../ast/unittest/emit/TernaryTest.class.php | 53 ++++++++++++++++++- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php index 43bfc290..27735487 100755 --- a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php @@ -155,7 +155,7 @@ public function run($arg) { public function concat_precedence() { $r= $this->run('class %T { public function run() { - return "te"."st" |> strtoupper(...); + return "te" . "st" |> strtoupper(...); } }'); @@ -187,8 +187,31 @@ public function run() { #[Test, Values([[0, 'even'], [1, 'odd'], [2, 'even']])] public function ternary_precedence($arg, $expected) { $r= $this->run('class %T { + + private function odd($n) { return $n % 2; } + + public function run($arg) { + return $arg |> $this->odd(...) ? "odd" : "even"; + } + }', $arg); + + Assert::equals($expected, $r); + } + + #[Test, Values([[0, '(empty)'], [1, 'one element'], [2, '2 elements']])] + public function short_ternary_precedence($arg, $expected) { + $r= $this->run('class %T { + + private function number($n) { + return match ($n) { + 0 => null, + 1 => "one element", + default => "{$n} elements" + }; + } + public function run($arg) { - return $arg |> fn($i) => $i % 2 ? "odd" : "even"; + return $arg |> $this->number(...) ?: "(empty)"; } }', $arg); diff --git a/src/test/php/lang/ast/unittest/emit/TernaryTest.class.php b/src/test/php/lang/ast/unittest/emit/TernaryTest.class.php index 39e9eb13..b0d7b133 100755 --- a/src/test/php/lang/ast/unittest/emit/TernaryTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/TernaryTest.class.php @@ -56,12 +56,61 @@ public function run($value) { #[Test, Values(eval: '[["."], [new Path(".")]]')] public function with_instanceof($value) { Assert::equals(new Path('.'), $this->run( - 'class %T { + 'use io\\Path; class %T { public function run($value) { - return $value instanceof \\io\\Path ? $value : new \\io\\Path($value); + return $value instanceof Path ? $value : new Path($value); } }', $value )); } + + #[Test, Values(['"te" . "st"', '1 + 2', '1 || 0', '2 ?? 1', '1 | 2', '4 === strlen("Test")'])] + public function precedence($lhs) { + Assert::equals('OK', $this->run( + 'class %T { + public function run() { + return '.$lhs.'? "OK" : "Error"; + } + }' + )); + } + + #[Test] + public function assignment_precedence() { + Assert::equals(['OK', 'OK'], $this->run( + 'class %T { + public function run() { + return [$a= 1 ? "OK" : "Error", $a]; + } + }' + )); + } + + #[Test] + public function yield_precedence() { + Assert::equals(['OK', null], iterator_to_array($this->run( + 'class %T { + public function run() { + yield (yield 1 ? "OK" : "Error"); + } + }' + ))); + } + + /** @see https://www.php.net/manual/en/language.operators.comparison.php#language.operators.comparison.ternary */ + #[Test] + public function chaining_short_ternaries() { + Assert::equals([1, 2, 3], $this->run( + 'class %T { + public function run() { + return [ + 0 ?: 1 ?: 2 ?: 3, + 0 ?: 0 ?: 2 ?: 3, + 0 ?: 0 ?: 0 ?: 3, + ]; + } + }' + )); + } } \ No newline at end of file From a7b48e1787d68110a6db6f65f45df33e22d6fd71 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 18 May 2025 13:21:33 +0200 Subject: [PATCH 13/15] Verify missing argument --- .../lang/ast/unittest/emit/PipelinesTest.class.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php index 27735487..6d017ef7 100755 --- a/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php @@ -110,7 +110,7 @@ public function run() { } #[Test, Expect(Error::class)] - public function pipe_to_missing() { + public function missing_function() { $this->run('class %T { public function run() { return "test" |> "__missing"; @@ -118,6 +118,15 @@ public function run() { }'); } + #[Test, Expect(Error::class)] + public function missing_argument() { + $this->run('class %T { + public function run() { + return 5 |> fn($a, $b) => $a * $b; + } + }'); + } + #[Test] public function pipe_chain() { $r= $this->run('class %T { From a259b31ec458215764d40b2c75d14dcf66882c88 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 18 May 2025 13:46:48 +0200 Subject: [PATCH 14/15] QA: Add `@test` references --- src/main/php/lang/ast/emit/PHP83.class.php | 1 + src/main/php/lang/ast/emit/PHP84.class.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/php/lang/ast/emit/PHP83.class.php b/src/main/php/lang/ast/emit/PHP83.class.php index 06879d88..101ed9f1 100755 --- a/src/main/php/lang/ast/emit/PHP83.class.php +++ b/src/main/php/lang/ast/emit/PHP83.class.php @@ -15,6 +15,7 @@ /** * PHP 8.3 syntax * + * @test lang.ast.unittest.emit.PHP83Test * @see https://wiki.php.net/rfc#php_83 */ class PHP83 extends PHP { diff --git a/src/main/php/lang/ast/emit/PHP84.class.php b/src/main/php/lang/ast/emit/PHP84.class.php index a9083a8f..54c0face 100755 --- a/src/main/php/lang/ast/emit/PHP84.class.php +++ b/src/main/php/lang/ast/emit/PHP84.class.php @@ -15,6 +15,7 @@ /** * PHP 8.4 syntax * + * @test lang.ast.unittest.emit.PHP84Test * @see https://wiki.php.net/rfc#php_84 */ class PHP84 extends PHP { From d309fea6308aa8093070c25c7260f48f54249cae Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Wed, 28 May 2025 21:28:54 +0200 Subject: [PATCH 15/15] Use AST library release version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ad441966..4ce18abd 100755 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "require" : { "xp-framework/core": "^12.0 | ^11.6 | ^10.16", "xp-framework/reflection": "^3.2 | ^2.15", - "xp-framework/ast": "dev-feature/pipelines as 11.6.0", + "xp-framework/ast": "^11.6", "php" : ">=7.4.0" }, "require-dev" : {