diff --git a/composer.json b/composer.json index f98e301e..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": "^11.5", + "xp-framework/ast": "^11.6", "php" : ">=7.4.0" }, "require-dev" : { 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..828f3829 --- /dev/null +++ b/src/main/php/lang/ast/emit/EmulatePipelines.class.php @@ -0,0 +1,49 @@ +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 63c5fed8..56d0be09 100755 --- a/src/main/php/lang/ast/emit/PHP.class.php +++ b/src/main/php/lang/ast/emit/PHP.class.php @@ -1156,6 +1156,22 @@ protected function emitNullsafeInstance($result, $instance) { $this->emitOne($result, $instance->member); } + protected function emitPipe($result, $pipe) { + $this->emitOne($result, $pipe->expression); + $result->out->write('|>'); + $this->emitOne($result, $pipe->target); + } + + protected function emitNullsafePipe($result, $pipe) { + + // $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:'.$t.'|>'); + $this->emitOne($result, $pipe->target); + } + protected function emitUnpack($result, $unpack) { $result->out->write('...'); $this->emitOne($result, $unpack->expression); 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..101ed9f1 100755 --- a/src/main/php/lang/ast/emit/PHP83.class.php +++ b/src/main/php/lang/ast/emit/PHP83.class.php @@ -15,10 +15,11 @@ /** * PHP 8.3 syntax * + * @test lang.ast.unittest.emit.PHP83Test * @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..54c0face 100755 --- a/src/main/php/lang/ast/emit/PHP84.class.php +++ b/src/main/php/lang/ast/emit/PHP84.class.php @@ -15,10 +15,11 @@ /** * PHP 8.4 syntax * + * @test lang.ast.unittest.emit.PHP84Test * @see https://wiki.php.net/rfc#php_84 */ class PHP84 extends PHP { - use RewriteBlockLambdaExpressions; + use EmulatePipelines, RewriteBlockLambdaExpressions; public $targetVersion= 80400; 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..6d017ef7 --- /dev/null +++ b/src/test/php/lang/ast/unittest/emit/PipelinesTest.class.php @@ -0,0 +1,349 @@ +run('class %T { + public function run() { + return "test" |> strtoupper(...); + } + }'); + + 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 { + 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, Expect(Error::class)] + public function pipe_to_throw() { + $this->run('use lang\Error; class %T { + public function run() { + return "test" |> throw new Error("Test"); + } + }'); + } + + #[Test, Expect(Error::class)] + public function missing_function() { + $this->run('class %T { + public function run() { + return "test" |> "__missing"; + } + }'); + } + + #[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 { + public function run() { + return " test " |> trim(...) |> strtoupper(...); + } + }'); + + Assert::equals('TEST', $r); + } + + #[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(...); + } + }', $input); + + Assert::equals($expected, $r); + } + + #[Test] + public function concat_precedence() { + $r= $this->run('class %T { + public function run() { + return "te" . "st" |> strtoupper(...); + } + }'); + + 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 { + + 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 |> $this->number(...) ?: "(empty)"; + } + }', $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 { + 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)); + } + + #[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 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