From c3dae273ac64122dc3073a3f43e46c91bdc140ea Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Mon, 7 Jul 2025 22:12:41 +0200 Subject: [PATCH 1/4] Add syntactic support for partial function application See https://wiki.php.net/rfc/partial_function_application_v2 --- .../ast/nodes/CallableExpression.class.php | 4 +- .../ast/nodes/CallableNewExpression.class.php | 4 +- .../php/lang/ast/nodes/Placeholder.class.php | 29 +++++++ src/main/php/lang/ast/syntax/PHP.class.php | 85 ++++++++++--------- .../ast/unittest/parse/InvokeTest.class.php | 31 ++++++- 5 files changed, 107 insertions(+), 46 deletions(-) create mode 100755 src/main/php/lang/ast/nodes/Placeholder.class.php diff --git a/src/main/php/lang/ast/nodes/CallableExpression.class.php b/src/main/php/lang/ast/nodes/CallableExpression.class.php index 841f9fc..55b7152 100755 --- a/src/main/php/lang/ast/nodes/CallableExpression.class.php +++ b/src/main/php/lang/ast/nodes/CallableExpression.class.php @@ -5,9 +5,11 @@ class CallableExpression extends Node { public $kind= 'callable'; public $expression; + public $arguments; - public function __construct($expression, $line= -1) { + public function __construct($expression, $arguments= [], $line= -1) { $this->expression= $expression; + $this->arguments= $arguments; $this->line= $line; } diff --git a/src/main/php/lang/ast/nodes/CallableNewExpression.class.php b/src/main/php/lang/ast/nodes/CallableNewExpression.class.php index 2fdba8c..1808a4b 100755 --- a/src/main/php/lang/ast/nodes/CallableNewExpression.class.php +++ b/src/main/php/lang/ast/nodes/CallableNewExpression.class.php @@ -5,9 +5,11 @@ class CallableNewExpression extends Node { public $kind= 'callablenew'; public $type; + public $arguments; - public function __construct($type, $line= -1) { + public function __construct($type, $arguments= [], $line= -1) { $this->type= $type; + $this->arguments= $arguments; $this->line= $line; } diff --git a/src/main/php/lang/ast/nodes/Placeholder.class.php b/src/main/php/lang/ast/nodes/Placeholder.class.php new file mode 100755 index 0000000..94f7bd5 --- /dev/null +++ b/src/main/php/lang/ast/nodes/Placeholder.class.php @@ -0,0 +1,29 @@ +literal= $literal; + } +} \ No newline at end of file diff --git a/src/main/php/lang/ast/syntax/PHP.class.php b/src/main/php/lang/ast/syntax/PHP.class.php index 5140ecd..c8a319a 100755 --- a/src/main/php/lang/ast/syntax/PHP.class.php +++ b/src/main/php/lang/ast/syntax/PHP.class.php @@ -45,6 +45,7 @@ OffsetExpression, Parameter, PipeExpression, + Placeholder, Property, ReturnStatement, ScopeExpression, @@ -151,15 +152,18 @@ public function __construct() { $scope= $left instanceof Literal ? $parse->scope->resolve($left->expression) : $left; $expr= $this->member($parse); + // Wrap self::member() into an invoke expression if ('(' === $parse->token->value) { $parse->expecting('(', 'invoke expression'); + list($arguments, $callable)= $this->arguments($parse); + $parse->expecting(')', 'invoke expression'); - if ($this->callable($parse)) { - return new CallableExpression(new ScopeExpression($scope, $expr, $token->line), $token->line); - } + if ($callable) return new CallableExpression( + new ScopeExpression($scope, $expr, $token->line), + $arguments, + $token->line + ); - $arguments= $this->arguments($parse); - $parse->expecting(')', 'invoke expression'); $expr= new InvokeExpression($expr, $arguments, $token->line); } @@ -177,16 +181,14 @@ public function __construct() { }); $this->infix('(', 100, function($parse, $token, $left) { + list($arguments, $callable)= $this->arguments($parse); + $parse->expecting(')', 'invoke expression'); - // Resolve ambiguity by looking ahead: `func(...)` which is a first-class - // callable reference vs. `func(...$it)` - a call with an unpacked argument - if ($this->callable($parse)) { - return new CallableExpression($left, $token->line); + if ($callable) { + return new CallableExpression($left, $arguments, $token->line); + } else { + return new InvokeExpression($left, $arguments, $left->line); } - - $arguments= $this->arguments($parse); - $parse->expecting(')', 'invoke expression'); - return new InvokeExpression($left, $arguments, $left->line); }); $this->infix('[', 100, function($parse, $token, $left) { @@ -281,12 +283,14 @@ public function __construct() { // clone $x vs. clone($x) or clone($x, ["id" => 6100]) if ('(' === $parse->token->value) { $parse->forward(); - if ($this->callable($parse)) { - return new CallableExpression(new Literal('clone', $token->line), $token->line); - } - - $arguments= $this->arguments($parse); + list($arguments, $callable)= $this->arguments($parse); $parse->expecting(')', 'clone arguments'); + + if ($callable) return new CallableExpression( + new Literal('clone', $token->line), + $arguments, + $token->line + ); } else { $arguments= [$this->expression($parse, 90)]; } @@ -336,10 +340,12 @@ public function __construct() { } $parse->expecting('(', 'new arguments'); + list($arguments, $callable)= $this->arguments($parse); + $parse->expecting(')', 'new arguments'); // Resolve ambiguity by looking ahead: `new T(...)` which is a first-class // callable reference vs. `new T(...$it)` - a call with an unpacked argument - if ($this->callable($parse)) { + if ($callable) { if (null === $type) { $class= $this->class($parse, null); $class->annotations= $annotations; @@ -348,12 +354,9 @@ public function __construct() { $new= new NewExpression($type, null, $token->line); } - return new CallableNewExpression($new, $token->line); + return new CallableNewExpression($new, $arguments, $token->line); } - $arguments= $this->arguments($parse); - $parse->expecting(')', 'new arguments'); - if (null === $type) { $class= $this->class($parse, null); $class->annotations= $annotations; @@ -1465,7 +1468,8 @@ private function annotations($parse, $context) { if ('(' === $parse->token->value) { $parse->expecting('(', $context); - $annotations->add(new Annotation($name, $this->arguments($parse), $parse->token->line)); + list($arguments, $callable)= $this->arguments($parse); + $annotations->add(new Annotation($name, $arguments, $parse->token->line)); $parse->expecting(')', $context); } else { $annotations->add(new Annotation($name, [], $parse->token->line)); @@ -1722,24 +1726,9 @@ public function class($parse, $name, $comment= null, $modifiers= []) { return $decl; } - public function callable($parse) { - if ('...' === $parse->token->value) { - $dots= $parse->token; - $parse->forward(); - if (')' === $parse->token->value) { - $parse->forward(); - return true; - } - - // Not first-class callable syntax but unpack - array_unshift($parse->queue, $parse->token); - $parse->token= $dots; - } - return false; - } - public function arguments($parse) { $arguments= []; + $callable= false; while (')' !== $parse->token->value) { // Named arguments (name: ) vs. positional arguments @@ -1754,6 +1743,20 @@ public function arguments($parse) { $parse->token= $token; $arguments[]= $this->expression($parse, 0); } + } else if ('?' === $parse->token->value) { + $callable= true; + $arguments[]= Placeholder::$ARGUMENT; + $parse->forward(); + } else if ('...' === $parse->token->value) { + $parse->forward(); + + // Resolve ambiguity between unpack and variadic placeholder at the end of arguments + if (')' === $parse->token->value) { + $callable= true; + $arguments[]= Placeholder::$VARIADIC; + } else { + $arguments[]= new UnpackExpression($this->expression($parse, 0), $parse->token->line); + } } else { $arguments[]= $this->expression($parse, 0); } @@ -1767,7 +1770,7 @@ public function arguments($parse) { break; } } - return $arguments; + return [$arguments, $callable]; } public function expressions($parse, $end) { diff --git a/src/test/php/lang/ast/unittest/parse/InvokeTest.class.php b/src/test/php/lang/ast/unittest/parse/InvokeTest.class.php index b934bfc..ee860be 100755 --- a/src/test/php/lang/ast/unittest/parse/InvokeTest.class.php +++ b/src/test/php/lang/ast/unittest/parse/InvokeTest.class.php @@ -9,6 +9,7 @@ UnpackExpression, ScopeExpression, Literal, + Placeholder, Variable }; use lang\ast\types\IsValue; @@ -86,7 +87,11 @@ public function argument_unpacking() { #[Test] public function first_class_callable_function() { $this->assertParsed( - [new CallableExpression(new Literal('strlen', self::LINE), self::LINE)], + [new CallableExpression( + new Literal('strlen', self::LINE), + [Placeholder::$VARIADIC], + self::LINE + )], 'strlen(...);' ); } @@ -94,7 +99,11 @@ public function first_class_callable_function() { #[Test] public function first_class_callable_static() { $this->assertParsed( - [new CallableExpression(new ScopeExpression('self', new Literal('length', self::LINE), self::LINE), self::LINE)], + [new CallableExpression( + new ScopeExpression('self', new Literal('length', self::LINE), self::LINE), + [Placeholder::$VARIADIC], + self::LINE + )], 'self::length(...);' ); } @@ -102,11 +111,27 @@ public function first_class_callable_static() { #[Test] public function first_class_callable_object_creation() { $this->assertParsed( - [new CallableNewExpression(new NewExpression(new IsValue('\\T'), null, self::LINE), self::LINE)], + [new CallableNewExpression( + new NewExpression(new IsValue('\\T'), null, self::LINE), + [Placeholder::$VARIADIC], + self::LINE + )], 'new T(...);' ); } + #[Test] + public function partial_function_application() { + $this->assertParsed( + [new CallableExpression( + new Literal('str_replace', self::LINE), + [new Literal('"test"', self::LINE), new Literal('"ok"', self::LINE), Placeholder::$ARGUMENT], + self::LINE + )], + 'str_replace("test", "ok", ?);' + ); + } + #[Test] public function chained_invocation_spanning_multiple_lines() { $expr= new InvokeExpression( From 9f2e48b3921ff1d4167e74f3a4856269895c4644 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 09:46:43 +0200 Subject: [PATCH 2/4] Use short list assignments --- src/main/php/lang/ast/nodes/Placeholder.class.php | 2 +- src/main/php/lang/ast/syntax/PHP.class.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/php/lang/ast/nodes/Placeholder.class.php b/src/main/php/lang/ast/nodes/Placeholder.class.php index 94f7bd5..715948c 100755 --- a/src/main/php/lang/ast/nodes/Placeholder.class.php +++ b/src/main/php/lang/ast/nodes/Placeholder.class.php @@ -7,7 +7,7 @@ * * - The argument place holder `?` means that exactly one argument is * expected at this position. - * - The variadic place holder `..`. means that zero or more arguments + * - The variadic place holder `...` means that zero or more arguments * may be supplied at this position. * * @see https://wiki.php.net/rfc/partial_function_application_v2 diff --git a/src/main/php/lang/ast/syntax/PHP.class.php b/src/main/php/lang/ast/syntax/PHP.class.php index c8a319a..21d160b 100755 --- a/src/main/php/lang/ast/syntax/PHP.class.php +++ b/src/main/php/lang/ast/syntax/PHP.class.php @@ -155,7 +155,7 @@ public function __construct() { // Wrap self::member() into an invoke expression if ('(' === $parse->token->value) { $parse->expecting('(', 'invoke expression'); - list($arguments, $callable)= $this->arguments($parse); + [$arguments, $callable]= $this->arguments($parse); $parse->expecting(')', 'invoke expression'); if ($callable) return new CallableExpression( @@ -181,7 +181,7 @@ public function __construct() { }); $this->infix('(', 100, function($parse, $token, $left) { - list($arguments, $callable)= $this->arguments($parse); + [$arguments, $callable]= $this->arguments($parse); $parse->expecting(')', 'invoke expression'); if ($callable) { @@ -283,7 +283,7 @@ public function __construct() { // clone $x vs. clone($x) or clone($x, ["id" => 6100]) if ('(' === $parse->token->value) { $parse->forward(); - list($arguments, $callable)= $this->arguments($parse); + [$arguments, $callable]= $this->arguments($parse); $parse->expecting(')', 'clone arguments'); if ($callable) return new CallableExpression( @@ -340,7 +340,7 @@ public function __construct() { } $parse->expecting('(', 'new arguments'); - list($arguments, $callable)= $this->arguments($parse); + [$arguments, $callable]= $this->arguments($parse); $parse->expecting(')', 'new arguments'); // Resolve ambiguity by looking ahead: `new T(...)` which is a first-class @@ -1468,7 +1468,7 @@ private function annotations($parse, $context) { if ('(' === $parse->token->value) { $parse->expecting('(', $context); - list($arguments, $callable)= $this->arguments($parse); + [$arguments, $callable]= $this->arguments($parse); $annotations->add(new Annotation($name, $arguments, $parse->token->line)); $parse->expecting(')', $context); } else { From 676ad7188d0064dd438554759a7074a621e91082 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 11:48:15 +0200 Subject: [PATCH 3/4] Allow named placeholder --- src/main/php/lang/ast/syntax/PHP.class.php | 18 +++++++++++------- .../ast/unittest/parse/InvokeTest.class.php | 12 ++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/main/php/lang/ast/syntax/PHP.class.php b/src/main/php/lang/ast/syntax/PHP.class.php index 21d160b..c709463 100755 --- a/src/main/php/lang/ast/syntax/PHP.class.php +++ b/src/main/php/lang/ast/syntax/PHP.class.php @@ -1737,15 +1737,19 @@ public function arguments($parse) { $parse->forward(); if (':' === $parse->token->value) { $parse->forward(); - $arguments[$token->value]= $this->expression($parse, 0); + $offset= $token->value; } else { array_unshift($parse->queue, $parse->token); $parse->token= $token; - $arguments[]= $this->expression($parse, 0); + $offset= sizeof($arguments); } - } else if ('?' === $parse->token->value) { + } else { + $offset= sizeof($arguments); + } + + if ('?' === $parse->token->value) { $callable= true; - $arguments[]= Placeholder::$ARGUMENT; + $arguments[$offset]= Placeholder::$ARGUMENT; $parse->forward(); } else if ('...' === $parse->token->value) { $parse->forward(); @@ -1753,12 +1757,12 @@ public function arguments($parse) { // Resolve ambiguity between unpack and variadic placeholder at the end of arguments if (')' === $parse->token->value) { $callable= true; - $arguments[]= Placeholder::$VARIADIC; + $arguments[$offset]= Placeholder::$VARIADIC; } else { - $arguments[]= new UnpackExpression($this->expression($parse, 0), $parse->token->line); + $arguments[$offset]= new UnpackExpression($this->expression($parse, 0), $parse->token->line); } } else { - $arguments[]= $this->expression($parse, 0); + $arguments[$offset]= $this->expression($parse, 0); } if (',' === $parse->token->value) { diff --git a/src/test/php/lang/ast/unittest/parse/InvokeTest.class.php b/src/test/php/lang/ast/unittest/parse/InvokeTest.class.php index ee860be..a0e6e13 100755 --- a/src/test/php/lang/ast/unittest/parse/InvokeTest.class.php +++ b/src/test/php/lang/ast/unittest/parse/InvokeTest.class.php @@ -132,6 +132,18 @@ public function partial_function_application() { ); } + #[Test] + public function partial_function_application_named() { + $this->assertParsed( + [new CallableExpression( + new Literal('str_replace', self::LINE), + [new Literal('"test"', self::LINE), new Literal('"ok"', self::LINE), 'subject' => Placeholder::$ARGUMENT], + self::LINE + )], + 'str_replace("test", "ok", subject: ?);' + ); + } + #[Test] public function chained_invocation_spanning_multiple_lines() { $expr= new InvokeExpression( From 88c517fd7a4caf3c5fff693d1de8bddcf96743b6 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 12 Jul 2025 12:16:23 +0200 Subject: [PATCH 4/4] Allow `...` placeholder to appear in the middle of arguments --- src/main/php/lang/ast/syntax/PHP.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/lang/ast/syntax/PHP.class.php b/src/main/php/lang/ast/syntax/PHP.class.php index c709463..154f249 100755 --- a/src/main/php/lang/ast/syntax/PHP.class.php +++ b/src/main/php/lang/ast/syntax/PHP.class.php @@ -1755,7 +1755,7 @@ public function arguments($parse) { $parse->forward(); // Resolve ambiguity between unpack and variadic placeholder at the end of arguments - if (')' === $parse->token->value) { + if (')' === $parse->token->value || ',' === $parse->token->value) { $callable= true; $arguments[$offset]= Placeholder::$VARIADIC; } else {