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/EmulatePipelines.class.php b/src/main/php/lang/ast/emit/EmulatePipelines.class.php index 1ec7a600..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 @@ 'strlen'; * strlen($in); * - * // Optimize for first-class callables: + * // 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 @@ -26,15 +30,29 @@ */ trait EmulatePipelines { + 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) { - $target->type->arguments= [$arg]; + 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) { + } else if ($target instanceof CallableExpression && ($pass= $this->passSingle($target->arguments, $arg))) { $this->emitOne($result, $target->expression); $result->out->write('('); - $this->emitOne($result, $arg); + $this->emitArguments($result, $pass); $result->out->write(')'); } else if ($target instanceof Literal) { $result->out->write(trim($target->expression, '"\'')); 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..60223c26 --- /dev/null +++ b/src/main/php/lang/ast/emit/RewritePartialFunctionApplications.class.php @@ -0,0 +1,76 @@ + str_replace('test', 'ok', $arg); + * ``` + * + * Keeps evaluation order consistent with native implementation: + * + * ```php + * // Input: + * $f= str_replace('test', result(), ?); + * + * // Output: + * $f= [ + * $temp= result(), + * fn($arg) => str_replace('test', $temp, $arg) + * ][1]; + * ``` + * + * @see https://wiki.php.net/rfc/partial_function_application_v2 + */ +trait RewritePartialFunctionApplications { + + protected function emitCallable($result, $callable) { + if ([Placeholder::$VARIADIC] !== $callable->arguments) { + $sig= ''; + $pass= $init= []; + foreach ($callable->arguments as $name => $argument) { + if (Placeholder::$VARIADIC === $argument) { + $t= $result->temp(); + $sig.= ',...'.$t; + $pass[$name]= new UnpackExpression(new Variable(substr($t, 1))); + } else if (Placeholder::$ARGUMENT === $argument) { + $t= $result->temp(); + $sig.= ','.$t; + $pass[$name]= new Variable(substr($t, 1)); + } else if ($this->isConstant($result, $argument)) { + $pass[$name]= $argument; + } else { + $t= $result->temp(); + $pass[$name]= 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(','); + } + } + + // Emit closure invoking the callable expression + $result->out->write('fn('.substr($sig, 1).')=>'); + $this->emitOne($result, $callable->expression); + $result->out->write('('); + $this->emitArguments($result, $pass); + $result->out->write(')'); + $init && $result->out->write(']['.sizeof($init).']'); + } 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..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,6 +1,8 @@ run($code)('Test')); } - #[Test] - public function native_function() { + #[Test, Values(['strlen(...)', 'strlen(?)'])] + public function returns_closure($notation) { + Assert::instance(Closure::class, $this->run('class %T { + public function run() { return '.$notation.'; } + }')); + } + + #[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.'; } }'); } @@ -151,8 +160,8 @@ public function run() { }'); } - #[Test] - public function instantiation() { + #[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(...); @@ -217,4 +226,210 @@ 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_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 { + 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'])); + } + + #[Test] + 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); + } + + #[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')); + } + + #[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); + } + + #[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 { + + public function run() { + return str_replace(search: "test", replace: "ok", subject: ?); + } + }'); + 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 { + + 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_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 { + + public function run() { + $f= str_replace("test", "ok", ?); + return $f(subject: "test."); + } + }'); + 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 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 79587772..c18ac127 100755 --- a/src/test/php/lang/ast/unittest/emit/EmulatePipelinesTest.class.php +++ b/src/test/php/lang/ast/unittest/emit/EmulatePipelinesTest.class.php @@ -54,4 +54,28 @@ public function to_callable_new() { $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 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( + '[$_0=strtoupper("hi"),trim($_0,"{}")][1];', + $this->emit('"hi" |> strtoupper(...) |> trim(?, "{}");') + ); + } } \ No newline at end of file