Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/main/php/lang/ast/nodes/CallableExpression.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a BC break! This will result in a 12.0.0-RELEASE and a dependency update for:

$ grep xp-framework/ast */composer.json | grep -v ^ast
compiler/composer.json:    "xp-framework/ast": "dev-feature/pfa as 11.8.0",
php-is-operator/composer.json:    "xp-framework/ast": "^11.8",
reflection/composer.json:    "xp-framework/ast": "^11.0 | ^10.0 | ^9.0 | ^8.0 | ^7.6",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member Author

@thekid thekid Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$this->expression= $expression;
$this->arguments= $arguments;
$this->line= $line;
}

Expand Down
4 changes: 3 additions & 1 deletion src/main/php/lang/ast/nodes/CallableNewExpression.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
29 changes: 29 additions & 0 deletions src/main/php/lang/ast/nodes/Placeholder.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php namespace lang\ast\nodes;

use lang\ast\Node;

/**
* These two placeholder symbols exist:
*
* - The argument place holder `?` means that exactly one argument is
* expected at this position.
* - 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
*/
class Placeholder extends Node {
public static $ARGUMENT, $VARIADIC;
public $literal;
public $kind= 'placeholder';

static function __static() {
self::$ARGUMENT= new self('?');
self::$VARIADIC= new self('...');
}

/** @param string $literal */
private function __construct($literal) {
$this->literal= $literal;
}
}
95 changes: 51 additions & 44 deletions src/main/php/lang/ast/syntax/PHP.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
OffsetExpression,
Parameter,
PipeExpression,
Placeholder,
Property,
ReturnStatement,
ScopeExpression,
Expand Down Expand Up @@ -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');
[$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);
}

Expand All @@ -177,16 +181,14 @@ public function __construct() {
});

$this->infix('(', 100, function($parse, $token, $left) {
[$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) {
Expand Down Expand Up @@ -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);
[$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)];
}
Expand Down Expand Up @@ -336,10 +340,12 @@ public function __construct() {
}

$parse->expecting('(', 'new arguments');
[$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;
Expand All @@ -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;
Expand Down Expand Up @@ -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));
[$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));
Expand Down Expand Up @@ -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: <argument>) vs. positional arguments
Expand All @@ -1748,14 +1737,32 @@ 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 {
$offset= sizeof($arguments);
}

if ('?' === $parse->token->value) {
$callable= true;
$arguments[$offset]= 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 || ',' === $parse->token->value) {
$callable= true;
$arguments[$offset]= Placeholder::$VARIADIC;
} else {
$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) {
Expand All @@ -1767,7 +1774,7 @@ public function arguments($parse) {
break;
}
}
return $arguments;
return [$arguments, $callable];
}

public function expressions($parse, $end) {
Expand Down
43 changes: 40 additions & 3 deletions src/test/php/lang/ast/unittest/parse/InvokeTest.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
UnpackExpression,
ScopeExpression,
Literal,
Placeholder,
Variable
};
use lang\ast\types\IsValue;
Expand Down Expand Up @@ -86,27 +87,63 @@ 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(...);'
);
}

#[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(...);'
);
}

#[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 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(
Expand Down
Loading