Add support for named arguments on macro calls and dot operator arguments

This commit is contained in:
Fabien Potencier
2024-10-10 10:43:40 +02:00
parent 4a762bda4f
commit 223d36bbae
14 changed files with 127 additions and 71 deletions
+2
View File
@@ -1,5 +1,7 @@
# 3.15.0 (2024-XX-XX) # 3.15.0 (2024-XX-XX)
* Add named arguments support for the dot operator arguments (`foo.bar(some: arg)`)
* Add named arguments support for macros
* Add a new `guard` tag that allows to test if some Twig callables are available at compilation time * Add a new `guard` tag that allows to test if some Twig callables are available at compilation time
* Allow arrow functions everywhere * Allow arrow functions everywhere
* Deprecate passing a string or an array to Twig callable arguments accepting arrow functions (pass a `\Closure`) * Deprecate passing a string or an array to Twig callable arguments accepting arrow functions (pass a `\Closure`)
+5 -2
View File
@@ -52,8 +52,8 @@ tag:
{% import "forms.twig" as forms %} {% import "forms.twig" as forms %}
The above ``import`` call imports the ``forms.twig`` file (which can contain The above ``import`` call imports the ``forms.twig`` file (which can contain
only macros, or a template and some macros), and import the macros as items of only macros, or a template and some macros), and import the macros as
the ``forms`` local variable. attributes of the ``forms`` local variable.
The macros can then be called at will in the *current* template: The macros can then be called at will in the *current* template:
@@ -61,6 +61,8 @@ The macros can then be called at will in the *current* template:
<p>{{ forms.input('username') }}</p> <p>{{ forms.input('username') }}</p>
<p>{{ forms.input('password', null, 'password') }}</p> <p>{{ forms.input('password', null, 'password') }}</p>
{# You can also use named arguments #}
<p>{{ forms.input(name: 'password', type: 'password') }}</p>
Alternatively you can import names from the template into the current namespace Alternatively you can import names from the template into the current namespace
via the ``from`` tag: via the ``from`` tag:
@@ -70,6 +72,7 @@ via the ``from`` tag:
{% from 'forms.twig' import input as input_field, textarea %} {% from 'forms.twig' import input as input_field, textarea %}
<p>{{ input_field('password', '', 'password') }}</p> <p>{{ input_field('password', '', 'password') }}</p>
<p>{{ input_field(name: 'password', type: 'password') }}</p>
<p>{{ textarea('comment') }}</p> <p>{{ textarea('comment') }}</p>
.. caution:: .. caution::
+29 -4
View File
@@ -225,7 +225,13 @@ built-in functions.
Named Arguments Named Arguments
--------------- ---------------
Named arguments are supported in functions, filters, and tests. Named arguments are supported everywhere you can pass arguments: functions,
filters, tests, macros, and dot operator arguments.
.. versionadded:: 3.15
Named arguments for macros and dot operator arguments were added in Twig
3.15.
.. versionadded:: 3.12 .. versionadded:: 3.12
@@ -873,12 +879,15 @@ The following operators don't fit into any of the other categories:
* ``.``, ``[]``: Gets an attribute of a variable. * ``.``, ``[]``: Gets an attribute of a variable.
The (``.``) operator abstracts getting an attribute of a variable (methods, The (``.``) operator abstracts getting an attribute of a variable (methods,
properties or constants of a PHP object, or items of a PHP array): properties or constants of a PHP object, or items of a PHP array):
.. code-block:: twig .. code-block:: twig
{{ user.name }} {{ user.name }}
Twig supports a specific syntax via the ``[]`` operator for accessing items
on sequences and mappings, like in ``user['name']``:
After the ``.``, you can use any expression by wrapping it with parenthesis After the ``.``, you can use any expression by wrapping it with parenthesis
``()``. ``()``.
@@ -900,6 +909,22 @@ The following operators don't fit into any of the other categories:
Before Twig 3.15, use the :doc:`attribute <functions/attribute>` function Before Twig 3.15, use the :doc:`attribute <functions/attribute>` function
instead for the two previous use cases. instead for the two previous use cases.
Twig supports a specific syntax via the ``[]`` operator for accessing items
on sequences and mappings:
.. code-block:: twig
{{ user['name'] }}
When calling a method, you can pass arguments using the ``()`` operator:
.. code-block:: twig
{{ html.generate_input() }}
{{ html.generate_input('pwd', 'password') }}
{# or using named arguments #}
{{ html.generate_input(name: 'pwd', type: 'password') }}
.. sidebar:: PHP Implementation .. sidebar:: PHP Implementation
To resolve ``user.name`` to a PHP call, Twig uses the following algorithm To resolve ``user.name`` to a PHP call, Twig uses the following algorithm
@@ -915,8 +940,8 @@ The following operators don't fit into any of the other categories:
* if not, and if ``strict_variables`` is ``false``, return ``null``; * if not, and if ``strict_variables`` is ``false``, return ``null``;
* if not, throw an exception. * if not, throw an exception.
Twig supports a specific syntax via the ``[]`` operator for accessing items To resolve ``user['name']`` to a PHP call, Twig uses the following algorithm
on sequences and mappings, like in ``user['name']``: at runtime:
* check if ``user`` is an array and ``name`` a valid element; * check if ``user`` is an array and ``name`` a valid element;
* if not, and if ``strict_variables`` is ``false``, return ``null``; * if not, and if ``strict_variables`` is ``false``, return ``null``;
+17 -11
View File
@@ -26,6 +26,7 @@ use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\GetAttrExpression; use Twig\Node\Expression\GetAttrExpression;
use Twig\Node\Expression\MethodCallExpression; use Twig\Node\Expression\MethodCallExpression;
use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\NameExpression;
use Twig\Node\Expression\TempNameExpression;
use Twig\Node\Expression\TestExpression; use Twig\Node\Expression\TestExpression;
use Twig\Node\Expression\Unary\AbstractUnary; use Twig\Node\Expression\Unary\AbstractUnary;
use Twig\Node\Expression\Unary\NegUnary; use Twig\Node\Expression\Unary\NegUnary;
@@ -530,11 +531,7 @@ class ExpressionParser
public function getFunctionNode($name, $line) public function getFunctionNode($name, $line)
{ {
if (null !== $alias = $this->parser->getImportedSymbol('function', $name)) { if (null !== $alias = $this->parser->getImportedSymbol('function', $name)) {
$arguments = new ArrayExpression([], $line); $arguments = $this->createArguments($line);
foreach ($this->parseArguments() as $n) {
$arguments->addElement($n);
}
$node = new MethodCallExpression($alias['node'], $alias['name'], $arguments, $line); $node = new MethodCallExpression($alias['node'], $alias['name'], $arguments, $line);
$node->setAttribute('safe', true); $node->setAttribute('safe', true);
@@ -575,9 +572,7 @@ class ExpressionParser
$stream->expect(Token::PUNCTUATION_TYPE, ')'); $stream->expect(Token::PUNCTUATION_TYPE, ')');
if ($stream->test(Token::PUNCTUATION_TYPE, '(')) { if ($stream->test(Token::PUNCTUATION_TYPE, '(')) {
$type = Template::METHOD_CALL; $type = Template::METHOD_CALL;
foreach ($this->parseArguments() as $n) { $arguments = $this->createArguments($lineno);
$arguments->addElement($n);
}
} }
return new GetAttrExpression($node, $arg, $arguments, $type, $lineno); return new GetAttrExpression($node, $arg, $arguments, $type, $lineno);
@@ -594,9 +589,7 @@ class ExpressionParser
if ($stream->test(Token::PUNCTUATION_TYPE, '(')) { if ($stream->test(Token::PUNCTUATION_TYPE, '(')) {
$type = Template::METHOD_CALL; $type = Template::METHOD_CALL;
foreach ($this->parseArguments() as $n) { $arguments = $this->createArguments($lineno);
$arguments->addElement($n);
}
} }
} else { } else {
throw new SyntaxError(\sprintf('Expected name or number, got value "%s" of type %s.', $token->getValue(), Token::typeToEnglish($token->getType())), $lineno, $stream->getSourceContext()); throw new SyntaxError(\sprintf('Expected name or number, got value "%s" of type %s.', $token->getValue(), Token::typeToEnglish($token->getType())), $lineno, $stream->getSourceContext());
@@ -708,6 +701,9 @@ class ExpressionParser
if (func_num_args() > 2) { if (func_num_args() > 2) {
trigger_deprecation('twig/twig', '3.15', 'Passing a third argument ($allowArrow) to "%s()" is deprecated.', __METHOD__); trigger_deprecation('twig/twig', '3.15', 'Passing a third argument ($allowArrow) to "%s()" is deprecated.', __METHOD__);
} }
if (!$namedArguments) {
trigger_deprecation('twig/twig', '3.15', 'Passing "false" for the first argument ($namedArguments) to "%s()" is deprecated.', __METHOD__);
}
$args = []; $args = [];
$stream = $this->parser->getStream(); $stream = $this->parser->getStream();
@@ -949,4 +945,14 @@ class ExpressionParser
return $current; return $current;
} }
private function createArguments(int $line): ArrayExpression
{
$arguments = new ArrayExpression([], $line);
foreach ($this->parseArguments(true) as $k => $n) {
$arguments->addElement($n, new TempNameExpression($k, $line));
}
return $arguments;
}
} }
+8 -1
View File
@@ -98,10 +98,17 @@ class ArrayExpression extends AbstractExpression
$compiler->raw('...')->subcompile($pair['value']); $compiler->raw('...')->subcompile($pair['value']);
++$nextIndex; ++$nextIndex;
} else { } else {
$key = $pair['key'] instanceof ConstantExpression ? $pair['key']->getAttribute('value') : null; $key = null;
if ($pair['key'] instanceof NameExpression) { if ($pair['key'] instanceof NameExpression) {
$pair['key'] = new StringCastUnary($pair['key'], $pair['key']->getTemplateLine()); $pair['key'] = new StringCastUnary($pair['key'], $pair['key']->getTemplateLine());
} }
if ($pair['key'] instanceof TempNameExpression) {
$key = $pair['key']->getAttribute('name');
$pair['key'] = new ConstantExpression($key, $pair['key']->getTemplateLine());
}
if ($pair['key'] instanceof ConstantExpression) {
$key = $pair['key']->getAttribute('value');
}
if ($nextIndex !== $key) { if ($nextIndex !== $key) {
if (\is_int($key)) { if (\is_int($key)) {
@@ -18,6 +18,10 @@ use Twig\Template;
class GetAttrExpression extends AbstractExpression class GetAttrExpression extends AbstractExpression
{ {
/**
* @param ArrayExpression|NameExpression|null $arguments
*/
public function __construct(AbstractExpression $node, AbstractExpression $attribute, ?AbstractExpression $arguments, string $type, int $lineno) public function __construct(AbstractExpression $node, AbstractExpression $attribute, ?AbstractExpression $arguments, string $type, int $lineno)
{ {
$nodes = ['node' => $node, 'attribute' => $attribute]; $nodes = ['node' => $node, 'attribute' => $attribute];
@@ -25,6 +29,10 @@ class GetAttrExpression extends AbstractExpression
$nodes['arguments'] = $arguments; $nodes['arguments'] = $arguments;
} }
if ($arguments && !$arguments instanceof ArrayExpression && !$arguments instanceof NameExpression) {
trigger_deprecation('twig/twig', '3.15', \sprintf('Not passing a "%s" instance as the "arguments" argument of the "%s" constructor is deprecated ("%s" given).', ArrayExpression::class, static::class, $arguments::class));
}
parent::__construct($nodes, ['type' => $type, 'is_defined_test' => false, 'ignore_strict_check' => false, 'optimizable' => true], $lineno); parent::__construct($nodes, ['type' => $type, 'is_defined_test' => false, 'ignore_strict_check' => false, 'optimizable' => true], $lineno);
} }
+3 -15
View File
@@ -43,21 +43,9 @@ class MethodCallExpression extends AbstractExpression
->repr($this->getNode('node')->getAttribute('name')) ->repr($this->getNode('node')->getAttribute('name'))
->raw('], ') ->raw('], ')
->repr($this->getAttribute('method')) ->repr($this->getAttribute('method'))
->raw(', [') ->raw(', ')
; ->subcompile($this->getNode('arguments'))
$first = true; ->raw(', ')
/** @var ArrayExpression */
$args = $this->getNode('arguments');
foreach ($args->getKeyValuePairs() as $pair) {
if (!$first) {
$compiler->raw(', ');
}
$first = false;
$compiler->subcompile($pair['value']);
}
$compiler
->raw('], ')
->repr($this->getTemplateLine()) ->repr($this->getTemplateLine())
->raw(', $context, $this->getSourceContext())'); ->raw(', $context, $this->getSourceContext())');
} }
+10 -6
View File
@@ -15,17 +15,21 @@ use Twig\Compiler;
class TempNameExpression extends AbstractExpression class TempNameExpression extends AbstractExpression
{ {
public function __construct(string $name, int $lineno) public const RESERVED_NAMES = ['varargs', 'context', 'macros', 'blocks', 'this'];
public function __construct(string|int $name, int $lineno)
{ {
if (is_int($name) || ctype_digit($name)) {
$name = (int) $name;
} elseif (in_array($name, self::RESERVED_NAMES)) {
$name = '_'.$name.'_';
}
parent::__construct([], ['name' => $name], $lineno); parent::__construct([], ['name' => $name], $lineno);
} }
public function compile(Compiler $compiler): void public function compile(Compiler $compiler): void
{ {
$compiler $compiler->raw('$'.$this->getAttribute('name'));
->raw('$_')
->raw($this->getAttribute('name'))
->raw('_')
;
} }
} }
+11 -6
View File
@@ -14,6 +14,7 @@ namespace Twig\Node;
use Twig\Attribute\YieldReady; use Twig\Attribute\YieldReady;
use Twig\Compiler; use Twig\Compiler;
use Twig\Error\SyntaxError; use Twig\Error\SyntaxError;
use Twig\Node\Expression\TempNameExpression;
/** /**
* Represents a macro node. * Represents a macro node.
@@ -31,13 +32,17 @@ class MacroNode extends Node
public function __construct(string $name, Node $body, Node $arguments, int $lineno) public function __construct(string $name, Node $body, Node $arguments, int $lineno)
{ {
if (!$body instanceof BodyNode) { if (!$body instanceof BodyNode) {
trigger_deprecation('twig/twig', '3.12', \sprintf('Not passing a "%s" instance as the "body" argument of the "%s" constructor is deprecated.', BodyNode::class, static::class)); trigger_deprecation('twig/twig', '3.12', \sprintf('Not passing a "%s" instance as the "body" argument of the "%s" constructor is deprecated ("%s" given).', BodyNode::class, static::class, $body::class));
} }
foreach ($arguments as $argumentName => $argument) { foreach ($arguments as $argumentName => $argument) {
if (self::VARARGS_NAME === $argumentName) { if (self::VARARGS_NAME === $argumentName) {
throw new SyntaxError(\sprintf('The argument "%s" in macro "%s" cannot be defined because the variable "%s" is reserved for arbitrary arguments.', self::VARARGS_NAME, $name, self::VARARGS_NAME), $argument->getTemplateLine(), $argument->getSourceContext()); throw new SyntaxError(\sprintf('The argument "%s" in macro "%s" cannot be defined because the variable "%s" is reserved for arbitrary arguments.', self::VARARGS_NAME, $name, self::VARARGS_NAME), $argument->getTemplateLine(), $argument->getSourceContext());
} }
if (in_array($argumentName, TempNameExpression::RESERVED_NAMES)) {
$arguments->setNode('_'.$argumentName.'_', $argument);
$arguments->removeNode($argumentName);
}
} }
parent::__construct(['body' => $body, 'arguments' => $arguments], ['name' => $name], $lineno); parent::__construct(['body' => $body, 'arguments' => $arguments], ['name' => $name], $lineno);
@@ -54,7 +59,7 @@ class MacroNode extends Node
$pos = 0; $pos = 0;
foreach ($this->getNode('arguments') as $name => $default) { foreach ($this->getNode('arguments') as $name => $default) {
$compiler $compiler
->raw('$__'.$name.'__ = ') ->raw('$'.$name.' = ')
->subcompile($default) ->subcompile($default)
; ;
@@ -68,7 +73,7 @@ class MacroNode extends Node
} }
$compiler $compiler
->raw('...$__varargs__') ->raw('...$varargs')
->raw(")\n") ->raw(")\n")
->write("{\n") ->write("{\n")
->indent() ->indent()
@@ -80,8 +85,8 @@ class MacroNode extends Node
foreach ($this->getNode('arguments') as $name => $default) { foreach ($this->getNode('arguments') as $name => $default) {
$compiler $compiler
->write('') ->write('')
->string($name) ->string(trim($name, '_'))
->raw(' => $__'.$name.'__') ->raw(' => $'.$name)
->raw(",\n") ->raw(",\n")
; ;
} }
@@ -92,7 +97,7 @@ class MacroNode extends Node
->write('') ->write('')
->string(self::VARARGS_NAME) ->string(self::VARARGS_NAME)
->raw(' => ') ->raw(' => ')
->raw("\$__varargs__,\n") ->raw("\$varargs,\n")
->outdent() ->outdent()
->write("] + \$this->env->getGlobals();\n\n") ->write("] + \$this->env->getGlobals();\n\n")
->write("\$blocks = [];\n\n") ->write("\$blocks = [];\n\n")
-18
View File
@@ -275,24 +275,6 @@ class ExpressionParserTest extends TestCase
]; ];
} }
public function testAttributeCallDoesNotSupportNamedArguments()
{
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
$parser = new Parser($env);
$this->expectException(SyntaxError::class);
$parser->parse($env->tokenize(new Source('{{ foo.bar(name="Foo") }}', 'index')));
}
public function testMacroCallDoesNotSupportNamedArguments()
{
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
$parser = new Parser($env);
$this->expectException(SyntaxError::class);
$parser->parse($env->tokenize(new Source('{% from _self import foo %}{% macro foo() %}{% endmacro %}{{ foo(name="Foo") }}', 'index')));
}
public function testMacroDefinitionDoesNotSupportNonNameVariableName() public function testMacroDefinitionDoesNotSupportNonNameVariableName()
{ {
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
@@ -4,6 +4,9 @@
{{ obj.(method) }} {{ obj.(method) }}
{{ array.(item) }} {{ array.(item) }}
{{ obj.("bar")("a", "b") }} {{ obj.("bar")("a", "b") }}
{{ obj.("bar")(param1: "a", param2: "b") }}
{{ obj.("bar")(param2: "b", param1: "a") }}
{{ obj.("bar")("a", param2: "b") }}
{{ obj.("bar")(...arguments) }} {{ obj.("bar")(...arguments) }}
{{ obj.(method) is defined ? 'ok' : 'ko' }} {{ obj.(method) is defined ? 'ok' : 'ko' }}
{{ obj.(nonmethod) is defined ? 'ok' : 'ko' }} {{ obj.(nonmethod) is defined ? 'ok' : 'ko' }}
@@ -14,5 +17,8 @@ foo
bar bar
bar_a-b bar_a-b
bar_a-b bar_a-b
bar_a-b
bar_a-b
bar_a-b
ok ok
ko ko
@@ -6,6 +6,9 @@ Twig supports method calls
{{ items.foo.bar }} {{ items.foo.bar }}
{{ items.foo['bar'] }} {{ items.foo['bar'] }}
{{ items.foo.bar('a', 43) }} {{ items.foo.bar('a', 43) }}
{{ items.foo.bar(param1: 'a', param2: 43) }}
{{ items.foo.bar(param2: 43, param1: 'a') }}
{{ items.foo.bar('a', param2: 43) }}
{{ items.foo.bar(foo) }} {{ items.foo.bar(foo) }}
{{ items.foo.self.foo() }} {{ items.foo.self.foo() }}
{{ items.foo.is }} {{ items.foo.is }}
@@ -20,6 +23,9 @@ foo
foo foo
bar bar
bar_a-43
bar_a-43
bar_a-43
bar_a-43 bar_a-43
bar_bar bar_bar
foo foo
@@ -0,0 +1,14 @@
--TEST--
"macro" tag
--TEMPLATE--
{% import _self as forms %}
{{ forms.input(size: 10, name: 'username') }}
{% macro input(name, value, type, size) %}
<input type="{{ type|default("text") }}" name="{{ name }}" value="{{ value|e|default('') }}" size="{{ size|default(20) }}">
{% endmacro %}
--DATA--
return []
--EXPECT--
<input type="text" name="username" value="" size="10">
+8 -8
View File
@@ -46,13 +46,13 @@ class MacroTest extends NodeTestCase
yield 'with use_yield = true' => [$node, <<<EOF yield 'with use_yield = true' => [$node, <<<EOF
// line 1 // line 1
public function macro_foo(\$__foo__ = null, \$__bar__ = "Foo", ...\$__varargs__) public function macro_foo(\$foo = null, \$bar = "Foo", ...\$varargs)
{ {
\$macros = \$this->macros; \$macros = \$this->macros;
\$context = [ \$context = [
"foo" => \$__foo__, "foo" => \$foo,
"bar" => \$__bar__, "bar" => \$bar,
"varargs" => \$__varargs__, "varargs" => \$varargs,
] + \$this->env->getGlobals(); ] + \$this->env->getGlobals();
\$blocks = []; \$blocks = [];
@@ -68,13 +68,13 @@ EOF
yield 'with use_yield = false' => [$node, <<<EOF yield 'with use_yield = false' => [$node, <<<EOF
// line 1 // line 1
public function macro_foo(\$__foo__ = null, \$__bar__ = "Foo", ...\$__varargs__) public function macro_foo(\$foo = null, \$bar = "Foo", ...\$varargs)
{ {
\$macros = \$this->macros; \$macros = \$this->macros;
\$context = [ \$context = [
"foo" => \$__foo__, "foo" => \$foo,
"bar" => \$__bar__, "bar" => \$bar,
"varargs" => \$__varargs__, "varargs" => \$varargs,
] + \$this->env->getGlobals(); ] + \$this->env->getGlobals();
\$blocks = []; \$blocks = [];