feature #4210 Special functions extension point (fabpot)

This PR was merged into the 3.x branch.

Discussion
----------

Special functions extension point

The `parent`, `attribute`, `block` (and `loop` in 4.0) functions are handled directly in ExpressionParser as they are converted to specific Nodes.

It means that recursive loops can only be implemented in core. By adding a new extension point, we are removing the hardcoded handling of such functions and we open up the possibility for user-land functions to do the same.

*Review is easier when hiding whitespace.*

Commits
-------

44280790 Add an extension point for parsing special functions
This commit is contained in:
Fabien Potencier
2024-08-17 23:05:16 +02:00
3 changed files with 83 additions and 51 deletions
+28 -51
View File
@@ -457,60 +457,37 @@ class ExpressionParser
public function getFunctionNode($name, $line)
{
switch ($name) {
case 'parent':
if (!\count($this->parser->getBlockStack())) {
throw new SyntaxError('Calling "parent" outside a block is forbidden.', $line, $this->parser->getStream()->getSourceContext());
}
if (null !== $alias = $this->parser->getImportedSymbol('function', $name)) {
$arguments = new ArrayExpression([], $line);
foreach ($this->parseArguments() as $n) {
$arguments->addElement($n);
}
if (!$this->parser->getParent() && !$this->parser->hasTraits()) {
throw new SyntaxError('Calling "parent" on a template that does not extend nor "use" another template is forbidden.', $line, $this->parser->getStream()->getSourceContext());
}
$node = new MethodCallExpression($alias['node'], $alias['name'], $arguments, $line);
$node->setAttribute('safe', true);
$this->parseArguments(true);
return new ParentExpression($this->parser->peekBlockStack(), $line);
case 'block':
$fakeNode = new Node(lineno: $line);
$fakeNode->setSourceContext($this->parser->getStream()->getSourceContext());
$fakeFunction = new TwigFunction('block', fn ($name, $template = null) => null);
$args = (new CallableArgumentsExtractor($fakeNode, $fakeFunction))->extractArguments($this->parseArguments(true));
return new BlockReferenceExpression($args[0], $args[1] ?? null, $line);
case 'attribute':
$fakeNode = new Node(lineno: $line);
$fakeNode->setSourceContext($this->parser->getStream()->getSourceContext());
$fakeFunction = new TwigFunction('attribute', fn ($variable, $attribute, $arguments = null) => null);
$args = (new CallableArgumentsExtractor($fakeNode, $fakeFunction))->extractArguments($this->parseArguments(true));
return new GetAttrExpression($args[0], $args[1], $args[2] ?? null, Template::ANY_CALL, $line);
default:
if (null !== $alias = $this->parser->getImportedSymbol('function', $name)) {
$arguments = new ArrayExpression([], $line);
foreach ($this->parseArguments() as $n) {
$arguments->addElement($n);
}
$node = new MethodCallExpression($alias['node'], $alias['name'], $arguments, $line);
$node->setAttribute('safe', true);
return $node;
}
$args = $this->parseArguments(true);
$function = $this->getFunction($name, $line);
$ready = true;
if (!isset($this->readyNodes[$class = $function->getNodeClass()])) {
$this->readyNodes[$class] = (bool) (new \ReflectionClass($class))->getConstructor()->getAttributes(FirstClassTwigCallableReady::class);
}
if (!$ready = $this->readyNodes[$class]) {
trigger_deprecation('twig/twig', '3.12', 'Twig node "%s" is not marked as ready for passing a "TwigFunction" in the constructor instead of its name; please update your code and then add #[FirstClassTwigCallableReady] attribute to the constructor.', $class);
}
return new $class($ready ? $function : $function->getName(), $args, $line);
return $node;
}
$args = $this->parseArguments(true);
$function = $this->getFunction($name, $line);
if ($function->getParserCallable()) {
$fakeNode = new Node(lineno: $line);
$fakeNode->setSourceContext($this->parser->getStream()->getSourceContext());
return ($function->getParserCallable())($this->parser, $fakeNode, $args, $line);
}
if (!isset($this->readyNodes[$class = $function->getNodeClass()])) {
$this->readyNodes[$class] = (bool) (new \ReflectionClass($class))->getConstructor()->getAttributes(FirstClassTwigCallableReady::class);
}
if (!$ready = $this->readyNodes[$class]) {
trigger_deprecation('twig/twig', '3.12', 'Twig node "%s" is not marked as ready for passing a "TwigFunction" in the constructor instead of its name; please update your code and then add #[FirstClassTwigCallableReady] attribute to the constructor.', $class);
}
return new $class($ready ? $function : $function->getName(), $args, $line);
}
public function parseSubscriptExpression($node)
+49
View File
@@ -14,8 +14,10 @@ namespace Twig\Extension;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\ExpressionParser;
use Twig\Markup;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\Binary\AddBinary;
use Twig\Node\Expression\Binary\AndBinary;
use Twig\Node\Expression\Binary\BitwiseAndBinary;
@@ -44,9 +46,12 @@ use Twig\Node\Expression\Binary\RangeBinary;
use Twig\Node\Expression\Binary\SpaceshipBinary;
use Twig\Node\Expression\Binary\StartsWithBinary;
use Twig\Node\Expression\Binary\SubBinary;
use Twig\Node\Expression\BlockReferenceExpression;
use Twig\Node\Expression\Filter\DefaultFilter;
use Twig\Node\Expression\FunctionNode\EnumCasesFunction;
use Twig\Node\Expression\GetAttrExpression;
use Twig\Node\Expression\NullCoalesceExpression;
use Twig\Node\Expression\ParentExpression;
use Twig\Node\Expression\Test\ConstantTest;
use Twig\Node\Expression\Test\DefinedTest;
use Twig\Node\Expression\Test\DivisiblebyTest;
@@ -57,7 +62,9 @@ use Twig\Node\Expression\Test\SameasTest;
use Twig\Node\Expression\Unary\NegUnary;
use Twig\Node\Expression\Unary\NotUnary;
use Twig\Node\Expression\Unary\PosUnary;
use Twig\Node\Node;
use Twig\NodeVisitor\MacroAutoImportNodeVisitor;
use Twig\Parser;
use Twig\Source;
use Twig\Template;
use Twig\TemplateWrapper;
@@ -80,6 +87,7 @@ use Twig\TokenParser\WithTokenParser;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;
use Twig\Util\CallableArgumentsExtractor;
final class CoreExtension extends AbstractExtension
{
@@ -238,6 +246,9 @@ final class CoreExtension extends AbstractExtension
public function getFunctions(): array
{
return [
new TwigFunction('parent', null, ['parser_callable' => [$this, 'parseParentFunction']]),
new TwigFunction('block', null, ['parser_callable' => [$this, 'parseBlockFunction']]),
new TwigFunction('attribute', null, ['parser_callable' => [$this, 'parseAttributeFunction']]),
new TwigFunction('max', 'max'),
new TwigFunction('min', 'min'),
new TwigFunction('range', 'range'),
@@ -1905,4 +1916,42 @@ final class CoreExtension extends AbstractExtension
return $output;
}
/**
* @internal
*/
public function parseParentFunction(Parser $parser, Node $fakeNode, $args, int $line): AbstractExpression
{
if (!\count($parser->getBlockStack())) {
throw new SyntaxError('Calling "parent" outside a block is forbidden.', $line, $parser->getStream()->getSourceContext());
}
if (!$parser->getParent() && !$parser->hasTraits()) {
throw new SyntaxError('Calling "parent" on a template that does not extend nor "use" another template is forbidden.', $line, $parser->getStream()->getSourceContext());
}
return new ParentExpression($parser->peekBlockStack(), $line);
}
/**
* @internal
*/
public function parseBlockFunction(Parser $parser, Node $fakeNode, $args, int $line): AbstractExpression
{
$fakeFunction = new TwigFunction('block', fn ($name, $template = null) => null);
$args = (new CallableArgumentsExtractor($fakeNode, $fakeFunction))->extractArguments($args);
return new BlockReferenceExpression($args[0], $args[1] ?? null, $line);
}
/**
* @internal
*/
public function parseAttributeFunction(Parser $parser, Node $fakeNode, $args, int $line): AbstractExpression
{
$fakeFunction = new TwigFunction('attribute', fn ($variable, $attribute, $arguments = null) => null);
$args = (new CallableArgumentsExtractor($fakeNode, $fakeFunction))->extractArguments($args);
return new GetAttrExpression($args[0], $args[1], $args[2] ?? null, Template::ANY_CALL, $line);
}
}
+6
View File
@@ -34,9 +34,15 @@ final class TwigFunction extends AbstractTwigCallable
'is_safe' => null,
'is_safe_callback' => null,
'node_class' => FunctionExpression::class,
'parser_callable' => null,
], $this->options);
}
public function getParserCallable(): ?callable
{
return $this->options['parser_callable'];
}
public function getSafe(Node $functionArgs): ?array
{
if (null !== $this->options['is_safe']) {