From 10eaf459d618aeeacad5fd2d5b5c9a51ded494be Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 09:42:36 +0200 Subject: [PATCH 01/17] Implement partial function application See https://wiki.php.net/rfc/partial_function_application_v2 --- composer.json | 2 +- .../ast/emit/CallablesAsClosures.class.php | 15 ++++-- src/main/php/lang/ast/emit/PHP.class.php | 1 + src/main/php/lang/ast/emit/PHP81.class.php | 2 +- src/main/php/lang/ast/emit/PHP82.class.php | 2 +- src/main/php/lang/ast/emit/PHP83.class.php | 8 ++- src/main/php/lang/ast/emit/PHP84.class.php | 7 ++- src/main/php/lang/ast/emit/PHP85.class.php | 2 +- .../ast/emit/RewriteCallableClone.class.php | 15 ------ .../lang/ast/emit/RewriteCallables.class.php | 21 ++++++++ ...writePartialFunctionApplications.class.php | 39 +++++++++++++++ .../emit/CallableSyntaxTest.class.php | 49 ++++++++++++++++++- 12 files changed, 136 insertions(+), 27 deletions(-) delete mode 100755 src/main/php/lang/ast/emit/RewriteCallableClone.class.php create mode 100755 src/main/php/lang/ast/emit/RewriteCallables.class.php create mode 100755 src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php diff --git a/composer.json b/composer.json index 6097b63e..71b601bd 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.7", + "xp-framework/ast": "dev-feature/pfa as 11.8.0", "php" : ">=7.4.0" }, "require-dev" : { diff --git a/src/main/php/lang/ast/emit/CallablesAsClosures.class.php b/src/main/php/lang/ast/emit/CallablesAsClosures.class.php index 1ea17e27..e2431eb5 100755 --- a/src/main/php/lang/ast/emit/CallablesAsClosures.class.php +++ b/src/main/php/lang/ast/emit/CallablesAsClosures.class.php @@ -1,24 +1,27 @@ "f" + // Rewrite f(...) => "f" $result->out->write('"'.trim($node, '"\'').'"'); } else if ($node instanceof InstanceExpression) { - // Rewrite $this->f => [$this, "f"] + // Rewrite $this->f(...) => [$this, "f"] $result->out->write('['); $this->emitOne($result, $node->expression); $result->out->write(','); @@ -26,7 +29,7 @@ private function emitQuoted($result, $node) { $result->out->write(']'); } else if ($node instanceof ScopeExpression) { - // Rewrite T::f => [T::class, "f"] + // Rewrite T::f(...) => [T::class, "f"] $result->out->write('['); if ($node->type instanceof Node) { $this->emitOne($result, $node->type); @@ -38,7 +41,7 @@ private function emitQuoted($result, $node) { $result->out->write(']'); } else if ($node instanceof Expression) { - // Rewrite T::{} => [T::class, ] + // Rewrite T::{}(...) => [T::class, ] $this->emitOne($result, $node->inline); } else { @@ -50,6 +53,8 @@ private function emitQuoted($result, $node) { protected function emitCallable($result, $callable) { if ($callable->expression instanceof Literal && 'clone' === $callable->expression->expression) { $result->out->write('fn($o) => clone $o'); + } else if ([Placeholder::$VARIADIC] !== $callable->arguments) { + $this->emitPartial($result, $callable); } else { $result->out->write('\Closure::fromCallable('); $this->emitQuoted($result, $callable->expression); diff --git a/src/main/php/lang/ast/emit/PHP.class.php b/src/main/php/lang/ast/emit/PHP.class.php index 7bbcee4c..f28d0e30 100755 --- a/src/main/php/lang/ast/emit/PHP.class.php +++ b/src/main/php/lang/ast/emit/PHP.class.php @@ -12,6 +12,7 @@ InstanceExpression, Literal, NewExpression, + Placeholder, Property, ScopeExpression, UnpackExpression, diff --git a/src/main/php/lang/ast/emit/PHP81.class.php b/src/main/php/lang/ast/emit/PHP81.class.php index 36fc0b49..170ecde4 100755 --- a/src/main/php/lang/ast/emit/PHP81.class.php +++ b/src/main/php/lang/ast/emit/PHP81.class.php @@ -22,7 +22,7 @@ class PHP81 extends PHP { use EmulatePipelines, RewriteBlockLambdaExpressions, - RewriteCallableClone, + RewriteCallables, RewriteCloneWith, 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 7e6df802..5a8c82d4 100755 --- a/src/main/php/lang/ast/emit/PHP82.class.php +++ b/src/main/php/lang/ast/emit/PHP82.class.php @@ -22,7 +22,7 @@ class PHP82 extends PHP { use EmulatePipelines, RewriteBlockLambdaExpressions, - RewriteCallableClone, + RewriteCallables, RewriteCloneWith, 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 4f62d440..312a056c 100755 --- a/src/main/php/lang/ast/emit/PHP83.class.php +++ b/src/main/php/lang/ast/emit/PHP83.class.php @@ -19,7 +19,13 @@ * @see https://wiki.php.net/rfc#php_83 */ class PHP83 extends PHP { - use EmulatePipelines, RewriteCallableClone, RewriteCloneWith, RewriteBlockLambdaExpressions, RewriteProperties; + use + EmulatePipelines, + RewriteCallables, + RewriteCloneWith, + 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 2673be75..bb967ce7 100755 --- a/src/main/php/lang/ast/emit/PHP84.class.php +++ b/src/main/php/lang/ast/emit/PHP84.class.php @@ -19,7 +19,12 @@ * @see https://wiki.php.net/rfc#php_84 */ class PHP84 extends PHP { - use EmulatePipelines, RewriteCallableClone, RewriteCloneWith, RewriteBlockLambdaExpressions; + use + EmulatePipelines, + RewriteCallables, + RewriteCloneWith, + RewriteBlockLambdaExpressions + ; public $targetVersion= 80400; diff --git a/src/main/php/lang/ast/emit/PHP85.class.php b/src/main/php/lang/ast/emit/PHP85.class.php index 693a102a..b533a858 100755 --- a/src/main/php/lang/ast/emit/PHP85.class.php +++ b/src/main/php/lang/ast/emit/PHP85.class.php @@ -19,7 +19,7 @@ * @see https://wiki.php.net/rfc#php_85 */ class PHP85 extends PHP { - use RewriteBlockLambdaExpressions, RewriteCallableClone, RewriteCloneWith; // TODO: Remove once PR is merged! + use RewriteBlockLambdaExpressions, RewriteCallables, RewriteCloneWith; // TODO: Remove once PR is merged! public $targetVersion= 80500; diff --git a/src/main/php/lang/ast/emit/RewriteCallableClone.class.php b/src/main/php/lang/ast/emit/RewriteCallableClone.class.php deleted file mode 100755 index 5379b720..00000000 --- a/src/main/php/lang/ast/emit/RewriteCallableClone.class.php +++ /dev/null @@ -1,15 +0,0 @@ -expression instanceof Literal && 'clone' === $callable->expression->expression) { - $result->out->write('fn($o) => clone $o'); - } else { - parent::emitCallable($result, $callable); - } - } -} \ No newline at end of file diff --git a/src/main/php/lang/ast/emit/RewriteCallables.class.php b/src/main/php/lang/ast/emit/RewriteCallables.class.php new file mode 100755 index 00000000..2c3bf9a6 --- /dev/null +++ b/src/main/php/lang/ast/emit/RewriteCallables.class.php @@ -0,0 +1,21 @@ +expression instanceof Literal && 'clone' === $callable->expression->expression) { + $result->out->write('fn($o) => clone $o'); + } else { + $this->emitPartial($result, $callable); + } + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php b/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php new file mode 100755 index 00000000..93afab1f --- /dev/null +++ b/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php @@ -0,0 +1,39 @@ +arguments) { + $pass= []; + $sig= ''; + foreach ($callable->arguments as $argument) { + if (Placeholder::$VARIADIC === $argument) { + $t= $result->temp(); + $pass[]= new UnpackExpression(new Variable(substr($t, 1))); + $sig.= ',... '.$t; + } else if (Placeholder::$ARGUMENT === $argument) { + $t= $result->temp(); + $pass[]= new Variable(substr($t, 1)); + $sig.= ','.$t; + } else { + $pass[]= $argument; + } + } + $result->out->write('fn('.substr($sig, 1).')=>'); + + $this->emitOne($result, $callable->expression); + $result->out->write('('); + $this->emitArguments($result, $pass); + $result->out->write(')'); + } else { + parent::emitCallable($result, $callable); + } + } +} \ No newline at end of file diff --git a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php index f12f5038..9e8963cd 100755 --- a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php @@ -23,12 +23,19 @@ private function verify($code) { } #[Test] - public function native_function() { + public function native_function_variadic() { $this->verify('class %T { public function run() { return strlen(...); } }'); } + #[Test] + public function native_function_argument() { + $this->verify('class %T { + public function run() { return strlen(?); } + }'); + } + #[Test] public function instance_method() { $this->verify('class %T { @@ -217,4 +224,44 @@ public function run() { }'); Assert::equals('cba', $f('abc')); } + + #[Test] + public function partial_function_application() { + $f= $this->run('class %T { + public function run() { + return str_replace("test", "ok", ?); + } + }'); + Assert::equals('ok', $f('test')); + } + + #[Test] + public function partial_function_application_multiple_arguments() { + $f= $this->run('class %T { + public function run() { + return str_replace("test", ?, ?); + } + }'); + Assert::equals('ok', $f('ok', 'test')); + } + + #[Test] + public function partial_function_application_variadic() { + $f= $this->run('class %T { + public function run() { + return str_replace("test", ...); + } + }'); + Assert::equals('ok', $f('ok', 'test')); + } + + #[Test] + public function partial_function_application_callable_syntax_mixed() { + $f= $this->run('class %T { + public function run() { + return array_map(strtoupper(...), ?); + } + }'); + Assert::equals(['ONE', 'TWO'], $f(['One', 'Two'])); + } } \ No newline at end of file From 02aa85bedaf96c920524484a60b53f9008c70281 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 10:24:05 +0200 Subject: [PATCH 02/17] Add test showing by-ref arguments do not work --- .../ast/unittest/emit/CallableSyntaxTest.class.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php index 9e8963cd..ee170698 100755 --- a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php @@ -1,6 +1,7 @@ =8.5.0')] + public function partial_function_application_variadic_optional_by_ref() { + $f= $this->run('class %T { + public function run() { + return str_replace("test", ...); + } + }'); + + $count= 0; + Assert::equals('ok', $f('ok', 'test', $count)); + Assert::equals(1, $count); + } } \ No newline at end of file From fa9bde8259b1030ab6279d6fd762dfcf2951897b Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 10:36:29 +0200 Subject: [PATCH 03/17] Add test showing execution order differs --- ...writePartialFunctionApplications.class.php | 10 +++++++- .../emit/CallableSyntaxTest.class.php | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php b/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php index 93afab1f..e0a69576 100755 --- a/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php +++ b/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php @@ -3,7 +3,15 @@ use lang\ast\nodes\{Placeholder, Variable, UnpackExpression}; /** - * Rewrites partial function application + * Rewrites partial function application as follows: + * + * ```php + * // Input: + * $f= str_replace('test', 'ok', ?); + * + * // Ouput: + * $f= fn($arg) => str_replace('test', 'ok', $arg); + * ``` * * @see https://wiki.php.net/rfc/partial_function_application_v2 */ diff --git a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php index ee170698..59c1a9e6 100755 --- a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php @@ -278,4 +278,29 @@ public function run() { Assert::equals('ok', $f('ok', 'test', $count)); Assert::equals(1, $count); } + + #[Test, Runtime(php: '>=8.5.0')] + public function partial_function_application_order() { + [$result, $invokations]= $this->run('class %T { + private $invokations= []; + + private function concat(... $args) { + $this->invokations[]= __FUNCTION__; + return implode("", $args); + } + + private function arg() { + $this->invokations[]= __FUNCTION__; + return "ed"; + } + + public function run() { + $f= $this->concat(?, $this->arg()); + $this->invokations[]= __FUNCTION__; + return [$f("test"), $this->invokations]; + } + }'); + Assert::equals('tested', $result); + Assert::equals(['arg', 'run', 'concat'], $invokations); + } } \ No newline at end of file From 15452e64240edd5a6b9b68ff9c5367556a934bd6 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 10:53:10 +0200 Subject: [PATCH 04/17] Make evaluation order consistent with PHP --- ...writePartialFunctionApplications.class.php | 38 +++++++++++-------- .../emit/CallableSyntaxTest.class.php | 2 +- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php b/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php index e0a69576..17cfeba3 100755 --- a/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php +++ b/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php @@ -1,16 +1,20 @@ str_replace('test', 'ok', $arg); + * $f= [ + * $a0= 'test', + * $a1= result(), + * fn($arg) => str_replace($a0, $a1, $arg) + * ][2]; * ``` * * @see https://wiki.php.net/rfc/partial_function_application_v2 @@ -19,27 +23,31 @@ trait RewritePartialFunctionApplications { protected function emitCallable($result, $callable) { if ([Placeholder::$VARIADIC] !== $callable->arguments) { - $pass= []; - $sig= ''; + $sig= $pass= ''; + $offset= 0; + $result->out->write('['); foreach ($callable->arguments as $argument) { + $t= $result->temp(); if (Placeholder::$VARIADIC === $argument) { - $t= $result->temp(); - $pass[]= new UnpackExpression(new Variable(substr($t, 1))); - $sig.= ',... '.$t; + $sig.= ',...'.$t; + $pass.= ',...'.$t; } else if (Placeholder::$ARGUMENT === $argument) { - $t= $result->temp(); - $pass[]= new Variable(substr($t, 1)); $sig.= ','.$t; + $pass.= ','.$t; } else { - $pass[]= $argument; + $pass.= ','.$t; + $result->out->write($t.'='); + $this->emitOne($result, $argument); + $result->out->write(','); + $offset++; } } - $result->out->write('fn('.substr($sig, 1).')=>'); + $result->out->write('fn('.substr($sig, 1).')=>'); $this->emitOne($result, $callable->expression); - $result->out->write('('); - $this->emitArguments($result, $pass); - $result->out->write(')'); + $result->out->write('('.substr($pass, 1).')'); + + $result->out->write(']['.$offset.']'); } else { parent::emitCallable($result, $callable); } diff --git a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php index 59c1a9e6..de7b90a1 100755 --- a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php @@ -279,7 +279,7 @@ public function run() { Assert::equals(1, $count); } - #[Test, Runtime(php: '>=8.5.0')] + #[Test] public function partial_function_application_order() { [$result, $invokations]= $this->run('class %T { private $invokations= []; From c833be0771ddf063cb050dda35ff11b81d228f27 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 11:04:51 +0200 Subject: [PATCH 05/17] Optimize passing constant expressions --- ...writePartialFunctionApplications.class.php | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php b/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php index 17cfeba3..1b47d9b1 100755 --- a/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php +++ b/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php @@ -1,20 +1,25 @@ str_replace('test', 'ok', $arg); + * + * // Input: + * $f= str_replace('test', result(), ?); + * * // Ouput: * $f= [ - * $a0= 'test', - * $a1= result(), - * fn($arg) => str_replace($a0, $a1, $arg) - * ][2]; + * $temp= result(), + * fn($arg) => str_replace('test', $temp, $arg) + * ][1]; * ``` * * @see https://wiki.php.net/rfc/partial_function_application_v2 @@ -23,31 +28,43 @@ trait RewritePartialFunctionApplications { protected function emitCallable($result, $callable) { if ([Placeholder::$VARIADIC] !== $callable->arguments) { - $sig= $pass= ''; - $offset= 0; - $result->out->write('['); + $sig= ''; + $pass= $init= []; foreach ($callable->arguments as $argument) { - $t= $result->temp(); if (Placeholder::$VARIADIC === $argument) { + $t= $result->temp(); $sig.= ',...'.$t; - $pass.= ',...'.$t; + $pass[]= new UnpackExpression(new Variable(substr($t, 1))); } else if (Placeholder::$ARGUMENT === $argument) { + $t= $result->temp(); $sig.= ','.$t; - $pass.= ','.$t; + $pass[]= new Variable(substr($t, 1)); + } else if ($this->isConstant($result, $argument)) { + $pass[]= $argument; } else { - $pass.= ','.$t; + $t= $result->temp(); + $pass[]= new Variable(substr($t, 1)); + $init[$t]= $argument; + } + } + + // Initialize any non-constant expressions in place + if ($init) { + $result->out->write('['); + foreach ($init as $t => $argument) { $result->out->write($t.'='); $this->emitOne($result, $argument); $result->out->write(','); - $offset++; } } + // Emit closure invoking the callable expression $result->out->write('fn('.substr($sig, 1).')=>'); $this->emitOne($result, $callable->expression); - $result->out->write('('.substr($pass, 1).')'); - - $result->out->write(']['.$offset.']'); + $result->out->write('('); + $this->emitArguments($result, $pass); + $result->out->write(')'); + $init && $result->out->write(']['.sizeof($init).']'); } else { parent::emitCallable($result, $callable); } From ea7d77e4b08d7a8d482e5ed9c377ebebb647ffef Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 11:11:30 +0200 Subject: [PATCH 06/17] QA: APIdoc readability --- .../ast/emit/RewritePartialFunctionApplications.class.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php b/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php index 1b47d9b1..abb9cb9d 100755 --- a/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php +++ b/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php @@ -11,11 +11,15 @@ * * // Output: * $f= fn($arg) => str_replace('test', 'ok', $arg); + * ``` + * + * Keeps evaluation order consistent with native implementation: * + * ```php * // Input: * $f= str_replace('test', result(), ?); * - * // Ouput: + * // Output: * $f= [ * $temp= result(), * fn($arg) => str_replace('test', $temp, $arg) From 69702104ebb1ba8cf11e1459ffbe9053e6b57c7c Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 11:12:54 +0200 Subject: [PATCH 07/17] Add test for PFA inside annotations See https://externals.io/message/127781#127793 --- .../ast/unittest/emit/CallableSyntaxTest.class.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php index de7b90a1..98e12719 100755 --- a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php @@ -303,4 +303,16 @@ public function run() { Assert::equals('tested', $result); Assert::equals(['arg', 'run', 'concat'], $invokations); } + + #[Test] + public function partial_function_application_inside_annotation() { + $f= $this->run('use lang\Reflection; class %T { + + #[Attr(strrev(?))] + public function run() { + return Reflection::of($this)->method("run")->annotation(Attr::class)->argument(0); + } + }'); + Assert::equals('cba', $f('abc')); + } } \ No newline at end of file From c31dedb5593c3a258a74f6e3e6db21fbbc049ddf Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 11:34:42 +0200 Subject: [PATCH 08/17] Fix PFA in combination with pipeline operator optimizations --- .../lang/ast/emit/EmulatePipelines.class.php | 20 ++++++++++++++++--- .../emit/CallableSyntaxTest.class.php | 10 ++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/main/php/lang/ast/emit/EmulatePipelines.class.php b/src/main/php/lang/ast/emit/EmulatePipelines.class.php index 828f3829..d6f65b98 100755 --- a/src/main/php/lang/ast/emit/EmulatePipelines.class.php +++ b/src/main/php/lang/ast/emit/EmulatePipelines.class.php @@ -1,22 +1,36 @@ $expr; + * ($expr)($in); + * + * // Optimize for first-class callables with single placeholder argument: + * $in |> strlen(...); + * strlen($in); + * ``` + * * @see https://wiki.php.net/rfc/pipe-operator-v3 * @see https://externals.io/message/107661#107670 * @test lang.ast.unittest.emit.PipelinesTest */ trait EmulatePipelines { + private function singlePlaceholder($arguments) { + return 1 === sizeof($arguments) && $arguments[0] instanceof Placeholder; + } + protected function emitPipeTarget($result, $target, $arg) { - if ($target instanceof CallableNewExpression) { + if ($target instanceof CallableNewExpression && $this->singlePlaceholder($target->arguments)) { $target->type->arguments= [new Variable(substr($arg, 1))]; $this->emitOne($result, $target->type); $target->type->arguments= null; - } else if ($target instanceof CallableExpression) { + } else if ($target instanceof CallableExpression && $this->singlePlaceholder($target->arguments)) { $this->emitOne($result, $target->expression); $result->out->write('('.$arg.')'); } else { diff --git a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php index 98e12719..b5b50776 100755 --- a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php @@ -315,4 +315,14 @@ public function run() { }'); Assert::equals('cba', $f('abc')); } + + #[Test] + public function partial_function_application_with_pipe() { + $r= $this->run('class %T { + public function run() { + return ["hello world"] |> array_map(str_replace("hello", "hi", ?), ?); + } + }'); + Assert::equals(['hi world'], $r); + } } \ No newline at end of file From bfa707ca4377fb02000260afa452ff826734acfe Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 11:38:30 +0200 Subject: [PATCH 09/17] Verify `?` placeholder also works in `new` --- .../ast/unittest/emit/CallableSyntaxTest.class.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php index b5b50776..adf62c5b 100755 --- a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php @@ -160,7 +160,7 @@ public function run() { } #[Test] - public function instantiation() { + public function instantiation_variadic() { $f= $this->run('use lang\ast\unittest\emit\Handle; class %T { public function run() { return new Handle(...); @@ -169,6 +169,16 @@ public function run() { Assert::equals(new Handle(1), $f(1)); } + #[Test] + public function instantiation_argument() { + $f= $this->run('use lang\ast\unittest\emit\Handle; class %T { + public function run() { + return new Handle(?); + } + }'); + Assert::equals(new Handle(1), $f(1)); + } + #[Test] public function instantiation_in_map() { $r= $this->run('use lang\ast\unittest\emit\Handle; class %T { From 4bc33f537da9f89dcad23a50b506a7a3bd6925d2 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 11:59:48 +0200 Subject: [PATCH 10/17] Implement support for named arguments --- ...writePartialFunctionApplications.class.php | 10 +++--- .../emit/CallableSyntaxTest.class.php | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php b/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php index abb9cb9d..60223c26 100755 --- a/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php +++ b/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php @@ -34,20 +34,20 @@ protected function emitCallable($result, $callable) { if ([Placeholder::$VARIADIC] !== $callable->arguments) { $sig= ''; $pass= $init= []; - foreach ($callable->arguments as $argument) { + foreach ($callable->arguments as $name => $argument) { if (Placeholder::$VARIADIC === $argument) { $t= $result->temp(); $sig.= ',...'.$t; - $pass[]= new UnpackExpression(new Variable(substr($t, 1))); + $pass[$name]= new UnpackExpression(new Variable(substr($t, 1))); } else if (Placeholder::$ARGUMENT === $argument) { $t= $result->temp(); $sig.= ','.$t; - $pass[]= new Variable(substr($t, 1)); + $pass[$name]= new Variable(substr($t, 1)); } else if ($this->isConstant($result, $argument)) { - $pass[]= $argument; + $pass[$name]= $argument; } else { $t= $result->temp(); - $pass[]= new Variable(substr($t, 1)); + $pass[$name]= new Variable(substr($t, 1)); $init[$t]= $argument; } } diff --git a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php index adf62c5b..398f7fd2 100755 --- a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php @@ -335,4 +335,38 @@ public function run() { }'); Assert::equals(['hi world'], $r); } + + #[Test] + public function partial_function_application_pass_named() { + $f= $this->run('class %T { + + public function run() { + return str_replace(search: "test", replace: "ok", subject: ?); + } + }'); + Assert::equals('ok.', $f('test.')); + } + + #[Test, Runtime(php: '>=8.0.0')] + public function partial_function_application_named_arguments_out_of_order() { + $f= $this->run('class %T { + + public function run() { + return str_replace(subject: ?, replace: "ok", search: "test"); + } + }'); + Assert::equals('ok.', $f('test.')); + } + + #[Test, Runtime(php: '>=8.5.0')] + public function partial_function_application_with_named() { + $r= $this->run('class %T { + + public function run() { + $f= str_replace("test", "ok", ?); + return $f(subject: "test."); + } + }'); + Assert::equals('ok.', $r); + } } \ No newline at end of file From 61c447631a4b5b25f079a074d9d85cd9b04c1a27 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 12:16:50 +0200 Subject: [PATCH 11/17] Show how the `...` placeholder may appear in the middle of arguments --- .../emit/CallableSyntaxTest.class.php | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php index 398f7fd2..57a3e1dc 100755 --- a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php @@ -276,19 +276,6 @@ public function run() { Assert::equals(['ONE', 'TWO'], $f(['One', 'Two'])); } - #[Test, Runtime(php: '>=8.5.0')] - public function partial_function_application_variadic_optional_by_ref() { - $f= $this->run('class %T { - public function run() { - return str_replace("test", ...); - } - }'); - - $count= 0; - Assert::equals('ok', $f('ok', 'test', $count)); - Assert::equals(1, $count); - } - #[Test] public function partial_function_application_order() { [$result, $invokations]= $this->run('class %T { @@ -358,6 +345,19 @@ public function run() { Assert::equals('ok.', $f('test.')); } + #[Test, Runtime(php: '>=8.5.0')] + public function partial_function_application_variadic_optional_by_ref() { + $f= $this->run('class %T { + public function run() { + return str_replace("test", "ok", ...); + } + }'); + + $count= 0; + Assert::equals('ok.', $f('test.', $count)); + Assert::equals(1, $count); + } + #[Test, Runtime(php: '>=8.5.0')] public function partial_function_application_with_named() { $r= $this->run('class %T { @@ -369,4 +369,16 @@ public function run() { }'); Assert::equals('ok.', $r); } + + #[Test, Runtime(php: '>=8.5.0')] + public function partial_function_application_variadic_before_named() { + $r= $this->run('class %T { + + public function run() { + $f= str_replace("test", ..., subject: ?); + return $f("ok", "test."); + } + }'); + Assert::equals('ok.', $r); + } } \ No newline at end of file From a7fa1485c994270ce0b8c0e3554674659a95695f Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 13:19:20 +0200 Subject: [PATCH 12/17] Optimize pipelines with single-placeholder PFAs --- .../lang/ast/emit/EmulatePipelines.class.php | 67 ++++++++++++----- .../emit/CallableSyntaxTest.class.php | 9 +++ .../emit/EmulatePipelinesTest.class.php | 73 +++++++++++++++++++ 3 files changed, 132 insertions(+), 17 deletions(-) create mode 100755 src/test/php/lang/ast/unittest/emit/EmulatePipelinesTest.class.php diff --git a/src/main/php/lang/ast/emit/EmulatePipelines.class.php b/src/main/php/lang/ast/emit/EmulatePipelines.class.php index d6f65b98..7241dc45 100755 --- a/src/main/php/lang/ast/emit/EmulatePipelines.class.php +++ b/src/main/php/lang/ast/emit/EmulatePipelines.class.php @@ -1,6 +1,6 @@ $expr; * ($expr)($in); * + * // Optimize for string literals: + * $in |> 'strlen'; + * strlen($in); + * * // Optimize for first-class callables with single placeholder argument: * $in |> strlen(...); * strlen($in); + * + * // Optimize for partial functions with single placeholder argument: + * $in |> str_replace('test', 'ok', ?); + * strlen('test', 'ok', $in); * ``` * * @see https://wiki.php.net/rfc/pipe-operator-v3 * @see https://externals.io/message/107661#107670 + * @test lang.ast.unittest.emit.EmulatePipelinesTest * @test lang.ast.unittest.emit.PipelinesTest */ trait EmulatePipelines { - private function singlePlaceholder($arguments) { - return 1 === sizeof($arguments) && $arguments[0] instanceof Placeholder; + private function passSingle($arguments, $arg) { + $placeholder= -1; + foreach ($arguments as $n => $argument) { + if ($argument instanceof Placeholder) { + if ($placeholder > -1) return null; + $placeholder= $n; + } + } + + $r= $arguments; + $r[$placeholder]= $arg; + return $r; } protected function emitPipeTarget($result, $target, $arg) { - if ($target instanceof CallableNewExpression && $this->singlePlaceholder($target->arguments)) { - $target->type->arguments= [new Variable(substr($arg, 1))]; + if ($target instanceof CallableNewExpression && ($pass= $this->passSingle($target->arguments, $arg))) { + $target->type->arguments= $pass; $this->emitOne($result, $target->type); $target->type->arguments= null; - } else if ($target instanceof CallableExpression && $this->singlePlaceholder($target->arguments)) { + } else if ($target instanceof CallableExpression && ($pass= $this->passSingle($target->arguments, $arg))) { $this->emitOne($result, $target->expression); - $result->out->write('('.$arg.')'); + $result->out->write('('); + $this->emitArguments($result, $pass); + $result->out->write(')'); + } else if ($target instanceof Literal) { + $result->out->write(trim($target->expression, '"\'')); + $result->out->write('('); + $this->emitOne($result, $arg); + $result->out->write(')'); } else { $result->out->write('('); $this->emitOne($result, $target); - $result->out->write(')('.$arg.')'); + $result->out->write(')('); + $this->emitOne($result, $arg); + $result->out->write(')'); } } 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]'); + // |> strtoupper(...) => strtoupper() + // |> strtoupper(...) => [$arg= , strtoupper($arg)][1] + if ($this->isConstant($result, $pipe->expression)) { + $this->emitPipeTarget($result, $pipe->target, $pipe->expression); + } else { + $t= $result->temp(); + $result->out->write('['.$t.'='); + $this->emitOne($result, $pipe->expression); + $result->out->write(','); + $this->emitPipeTarget($result, $pipe->target, new Variable(substr($t, 1))); + $result->out->write('][1]'); + } } protected function emitNullsafePipe($result, $pipe) { - // $expr ?|> strtoupper(...) => null === ($arg= $expr) ? null : strtoupper($arg) + // ?|> strtoupper(...) => null === ($arg= ) ? 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); + $this->emitPipeTarget($result, $pipe->target, new Variable(substr($t, 1))); } } \ No newline at end of file diff --git a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php index 57a3e1dc..4b4f4ce8 100755 --- a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php @@ -323,6 +323,15 @@ public function run() { Assert::equals(['hi world'], $r); } + #[Test, Expect(class: Error::class, message: '/Too few arguments/')] + public function partial_function_application_returned_by_pipe() { + $this->run('class %T { + public function run() { + "hi" |> str_replace("hello", ?, ?); + } + }'); + } + #[Test] public function partial_function_application_pass_named() { $f= $this->run('class %T { diff --git a/src/test/php/lang/ast/unittest/emit/EmulatePipelinesTest.class.php b/src/test/php/lang/ast/unittest/emit/EmulatePipelinesTest.class.php new file mode 100755 index 00000000..2040cb0e --- /dev/null +++ b/src/test/php/lang/ast/unittest/emit/EmulatePipelinesTest.class.php @@ -0,0 +1,73 @@ +emit('result() |> $expr;') + ); + } + + #[Test] + public function to_expression() { + Assert::equals( + '($expr)("hi");', + $this->emit('"hi" |> $expr;') + ); + } + + #[Test, Values(['"strlen"', "'strlen'"])] + public function to_string_literal($notation) { + Assert::equals( + 'strlen("hi");', + $this->emit('"hi" |> '.$notation.';') + ); + } + + #[Test] + public function to_array_literal() { + Assert::equals( + '([$this,"func",])("hi");', + $this->emit('"hi" |> [$this, "func"];') + ); + } + + #[Test] + public function to_first_class_callable() { + Assert::equals( + 'strlen("hi");', + $this->emit('"hi" |> strlen(...);') + ); + } + + #[Test] + public function to_callable_new() { + Assert::equals( + 'new \\util\\Date("2025-07-12");', + $this->emit('"2025-07-12" |> new \\util\\Date(...);') + ); + } + + #[Test] + public function to_partial_function_application() { + Assert::equals( + 'str_replace("hi","hello","hi");', + $this->emit('"hi" |> str_replace("hi", "hello", ?);') + ); + } + + #[Test] + public function chained() { + Assert::equals( + '[$_0=strtoupper("hi"),trim($_0,"{}")][1];', + $this->emit('"hi" |> strtoupper(...) |> trim(?, "{}");') + ); + } +} \ No newline at end of file From e0bdbd90047adce0a75f117b63de9ba28d740d7c Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 13:22:54 +0200 Subject: [PATCH 13/17] Add test for partial constructor application --- .../lang/ast/unittest/emit/EmulatePipelinesTest.class.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/test/php/lang/ast/unittest/emit/EmulatePipelinesTest.class.php b/src/test/php/lang/ast/unittest/emit/EmulatePipelinesTest.class.php index 2040cb0e..c18ac127 100755 --- a/src/test/php/lang/ast/unittest/emit/EmulatePipelinesTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/EmulatePipelinesTest.class.php @@ -63,6 +63,14 @@ public function to_partial_function_application() { ); } + #[Test] + public function to_partial_constructor_application() { + Assert::equals( + 'new \\util\\Date("2025-07-12",$timezone);', + $this->emit('"2025-07-12" |> new \\util\\Date(?, $timezone);') + ); + } + #[Test] public function chained() { Assert::equals( From eef8baf3d4c970da1de77e2e1efa80bee367f2f0 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 13:34:25 +0200 Subject: [PATCH 14/17] Add test for static methods used w/ partial function applications --- .../unittest/emit/CallableSyntaxTest.class.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php index 4b4f4ce8..cb77cfb8 100755 --- a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php @@ -256,6 +256,24 @@ public function run() { Assert::equals('ok', $f('ok', 'test')); } + #[Test] + public function partial_function_application_static_method() { + $f= $this->run('use lang\ast\unittest\emit\Handle; class %T { + private static function for($impl, $stream) { + return match ($stream) { + STDIN => new $impl(0), + STDOUT => new $impl(1), + STDERR => new $impl(2), + }; + } + + public function run() { + return self::for(Handle::class, ?); + } + }'); + Assert::equals(new Handle(2), $f(STDERR)); + } + #[Test] public function partial_function_application_variadic() { $f= $this->run('class %T { From c247e5ff9447168b7eebfe0e0293891a06e08a4d Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 13:39:30 +0200 Subject: [PATCH 15/17] Test PFAs work with __invoke() and __call() --- .../emit/CallableSyntaxTest.class.php | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php index cb77cfb8..5ef84e0e 100755 --- a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php @@ -361,6 +361,39 @@ public function run() { Assert::equals('ok.', $f('test.')); } + #[Test] + public function partial_function_application_invoke_interceptor() { + $f= $this->run('use lang\ast\unittest\emit\Handle; class %T { + public function run() { + $add= new class() { + public function __invoke(int $a, int $b) { + return $a + $b; + } + }; + return $add(1, ?); + } + }'); + Assert::equals(3, $f(2)); + } + + #[Test] + public function partial_function_application_call_interceptor() { + $f= $this->run('use lang\ast\unittest\emit\Handle; class %T { + public function run() { + $calc= new class() { + public function __call($name, $args) { + return match ($name) { + "add" => array_sum($args), + // TBI + }; + } + }; + return $calc->add(1, ...); + } + }'); + Assert::equals(6, $f(2, 3)); + } + #[Test, Runtime(php: '>=8.0.0')] public function partial_function_application_named_arguments_out_of_order() { $f= $this->run('class %T { From 5134c3f0dc46049fa1091e7dc6e013a91d84ca41 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 13:53:34 +0200 Subject: [PATCH 16/17] Verify first-class callable and PFAs both return `Closure` instances --- .../emit/CallableSyntaxTest.class.php | 43 ++++++++----------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php index 5ef84e0e..efab5c78 100755 --- a/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/CallableSyntaxTest.class.php @@ -1,5 +1,6 @@ run($code)('Test')); } - #[Test] - public function native_function_variadic() { - $this->verify('class %T { - public function run() { return strlen(...); } - }'); + #[Test, Values(['strlen(...)', 'strlen(?)'])] + public function returns_closure($notation) { + Assert::instance(Closure::class, $this->run('class %T { + public function run() { return '.$notation.'; } + }')); } - #[Test] - public function native_function_argument() { + #[Test, Values(['strlen(...)', 'strlen(?)'])] + public function native_function($notation) { $this->verify('class %T { - public function run() { return strlen(?); } + public function run() { return '.$notation.'; } }'); } - #[Test] - public function instance_method() { + #[Test, Values(['$this->length(...)', '$this->length(?)'])] + public function instance_method($notation) { $this->verify('class %T { public function length($arg) { return strlen($arg); } - public function run() { return $this->length(...); } + public function run() { return '.$notation.'; } }'); } - #[Test] - public function class_method() { + #[Test, Values(['self::length(...)', 'self::length(?)'])] + public function class_method($notation) { $this->verify('class %T { public static function length($arg) { return strlen($arg); } - public function run() { return self::length(...); } + public function run() { return '.$notation.'; } }'); } @@ -159,8 +160,8 @@ public function run() { }'); } - #[Test] - public function instantiation_variadic() { + #[Test, Values(['new Handle(...)', 'new Handle(?)'])] + public function instantiation($notation) { $f= $this->run('use lang\ast\unittest\emit\Handle; class %T { public function run() { return new Handle(...); @@ -169,16 +170,6 @@ public function run() { Assert::equals(new Handle(1), $f(1)); } - #[Test] - public function instantiation_argument() { - $f= $this->run('use lang\ast\unittest\emit\Handle; class %T { - public function run() { - return new Handle(?); - } - }'); - Assert::equals(new Handle(1), $f(1)); - } - #[Test] public function instantiation_in_map() { $r= $this->run('use lang\ast\unittest\emit\Handle; class %T { From 314383ba6c5fa915eaa3ddd2f7b8212399d839a7 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 13:59:52 +0200 Subject: [PATCH 17/17] QA: Remove unused import --- src/main/php/lang/ast/emit/PHP.class.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/php/lang/ast/emit/PHP.class.php b/src/main/php/lang/ast/emit/PHP.class.php index f28d0e30..7bbcee4c 100755 --- a/src/main/php/lang/ast/emit/PHP.class.php +++ b/src/main/php/lang/ast/emit/PHP.class.php @@ -12,7 +12,6 @@ InstanceExpression, Literal, NewExpression, - Placeholder, Property, ScopeExpression, UnpackExpression,