diff --git a/doc/tags/macro.rst b/doc/tags/macro.rst
index d1d0641c0..13c032915 100644
--- a/doc/tags/macro.rst
+++ b/doc/tags/macro.rst
@@ -52,8 +52,8 @@ tag:
{% import "forms.twig" as forms %}
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
-the ``forms`` local variable.
+only macros, or a template and some macros), and import the macros as
+attributes of the ``forms`` local variable.
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:
{{ forms.input('username') }}
{{ forms.input('password', null, 'password') }}
+ {# You can also use named arguments #}
+ {{ forms.input(name: 'password', type: 'password') }}
Alternatively you can import names from the template into the current namespace
via the ``from`` tag:
@@ -70,6 +72,7 @@ via the ``from`` tag:
{% from 'forms.twig' import input as input_field, textarea %}
{{ input_field('password', '', 'password') }}
+ {{ input_field(name: 'password', type: 'password') }}
{{ textarea('comment') }}
.. caution::
diff --git a/doc/templates.rst b/doc/templates.rst
index a1f3363f7..872c3cae2 100644
--- a/doc/templates.rst
+++ b/doc/templates.rst
@@ -225,7 +225,13 @@ built-in functions.
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.
.. code-block:: twig
@@ -864,12 +870,15 @@ The following operators don't fit into any of the other categories:
* ``.``, ``[]``: Gets an attribute of a variable.
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
{{ 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
``()``.
@@ -891,6 +900,22 @@ The following operators don't fit into any of the other categories:
Before Twig 3.15, use the :doc:`attribute ` function
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
To resolve ``user.name`` to a PHP call, Twig uses the following algorithm
@@ -906,8 +931,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, throw an exception.
- Twig supports a specific syntax via the ``[]`` operator for accessing items
- on sequences and mappings, like in ``user['name']``:
+ To resolve ``user['name']`` to a PHP call, Twig uses the following algorithm
+ at runtime:
* check if ``user`` is an array and ``name`` a valid element;
* if not, and if ``strict_variables`` is ``false``, return ``null``;
diff --git a/src/ExpressionParser.php b/src/ExpressionParser.php
index e9c5fd3e8..b01a75540 100644
--- a/src/ExpressionParser.php
+++ b/src/ExpressionParser.php
@@ -25,6 +25,7 @@ use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\GetAttrExpression;
use Twig\Node\Expression\MethodCallExpression;
use Twig\Node\Expression\NameExpression;
+use Twig\Node\Expression\TempNameExpression;
use Twig\Node\Expression\TestExpression;
use Twig\Node\Expression\Unary\AbstractUnary;
use Twig\Node\Expression\Unary\NegUnary;
@@ -128,7 +129,7 @@ class ExpressionParser
return $expr;
}
- private function triggerPrecedenceDeprecations(AbstractExpression $expr, Token $token): void
+ private function triggerPrecedenceDeprecations(AbstractExpression $expr): void
{
// Check that the all nodes that are between the 2 precedences have explicit parentheses
if (!$expr->hasAttribute('operator') || !isset($this->precedenceChanges[$expr->getAttribute('operator')])) {
@@ -331,6 +332,14 @@ class ExpressionParser
$node = $this->parseStringExpression();
break;
+ case Token::PUNCTUATION_TYPE:
+ $node = match ($token->getValue()) {
+ '[' => $this->parseSequenceExpression(),
+ '{' => $this->parseMappingExpression(),
+ default => throw new SyntaxError(\sprintf('Unexpected token "%s" of value "%s".', Token::typeToEnglish($token->getType()), $token->getValue()), $token->getLine(), $this->parser->getStream()->getSourceContext()),
+ };
+ break;
+
case Token::OPERATOR_TYPE:
if (preg_match(Lexer::REGEX_NAME, $token->getValue(), $matches) && $matches[0] == $token->getValue()) {
// in this context, string operators are variable names
@@ -339,30 +348,13 @@ class ExpressionParser
break;
}
- if (isset($this->unaryOperators[$token->getValue()])) {
- $class = $this->unaryOperators[$token->getValue()]['class'];
- if (!\in_array($class, [NegUnary::class, PosUnary::class])) {
- throw new SyntaxError(\sprintf('Unexpected unary operator "%s".', $token->getValue()), $token->getLine(), $this->parser->getStream()->getSourceContext());
- }
-
- $this->parser->getStream()->next();
- $expr = $this->parsePrimaryExpression();
-
- $node = new $class($expr, $token->getLine());
- break;
+ if ('=' === $token->getValue() && ('==' === $this->parser->getStream()->look(-1)->getValue() || '!=' === $this->parser->getStream()->look(-1)->getValue())) {
+ throw new SyntaxError(\sprintf('Unexpected operator of value "%s". Did you try to use "===" or "!==" for strict comparison? Use "is same as(value)" instead.', $token->getValue()), $token->getLine(), $this->parser->getStream()->getSourceContext());
}
// no break
default:
- if ($token->test(Token::PUNCTUATION_TYPE, '[')) {
- $node = $this->parseSequenceExpression();
- } elseif ($token->test(Token::PUNCTUATION_TYPE, '{')) {
- $node = $this->parseMappingExpression();
- } elseif ($token->test(Token::OPERATOR_TYPE, '=') && ('==' === $this->parser->getStream()->look(-1)->getValue() || '!=' === $this->parser->getStream()->look(-1)->getValue())) {
- throw new SyntaxError(\sprintf('Unexpected operator of value "%s". Did you try to use "===" or "!==" for strict comparison? Use "is same as(value)" instead.', $token->getValue()), $token->getLine(), $this->parser->getStream()->getSourceContext());
- } else {
- throw new SyntaxError(\sprintf('Unexpected token "%s" of value "%s".', Token::typeToEnglish($token->getType()), $token->getValue()), $token->getLine(), $this->parser->getStream()->getSourceContext());
- }
+ throw new SyntaxError(\sprintf('Unexpected token "%s" of value "%s".', Token::typeToEnglish($token->getType()), $token->getValue()), $token->getLine(), $this->parser->getStream()->getSourceContext());
}
return $this->parsePostfixExpression($node);
@@ -506,11 +498,7 @@ class ExpressionParser
public function getFunctionNode($name, $line)
{
if (null !== $alias = $this->parser->getImportedSymbol('function', $name)) {
- $arguments = new ArrayExpression([], $line);
- foreach ($this->parseArguments() as $n) {
- $arguments->addElement($n);
- }
-
+ $arguments = $this->createArguments($line);
$node = new MethodCallExpression($alias['node'], $alias['name'], $arguments, $line);
$node->setAttribute('safe', true);
@@ -543,9 +531,7 @@ class ExpressionParser
$stream->expect(Token::PUNCTUATION_TYPE, ')');
if ($stream->test(Token::PUNCTUATION_TYPE, '(')) {
$type = Template::METHOD_CALL;
- foreach ($this->parseArguments() as $n) {
- $arguments->addElement($n);
- }
+ $arguments = $this->createArguments($lineno);
}
return new GetAttrExpression($node, $arg, $arguments, $type, $lineno);
@@ -562,9 +548,7 @@ class ExpressionParser
if ($stream->test(Token::PUNCTUATION_TYPE, '(')) {
$type = Template::METHOD_CALL;
- foreach ($this->parseArguments() as $n) {
- $arguments->addElement($n);
- }
+ $arguments = $this->createArguments($lineno);
}
} else {
throw new SyntaxError(\sprintf('Expected name or number, got value "%s" of type %s.', $token->getValue(), Token::typeToEnglish($token->getType())), $lineno, $stream->getSourceContext());
@@ -660,6 +644,10 @@ class ExpressionParser
*/
public function parseArguments($namedArguments = false, $definition = false)
{
+ if (!$namedArguments) {
+ trigger_deprecation('twig/twig', '3.15', 'Passing "false" for the first argument ($namedArguments) to "%s()" is deprecated.', __METHOD__);
+ }
+
$args = [];
$stream = $this->parser->getStream();
@@ -697,7 +685,7 @@ class ExpressionParser
$name = $value->getAttribute('name');
if ($definition) {
- $value = $this->parsePrimaryExpression();
+ $value = $this->getPrimary();
if (!$this->checkConstantExpression($value)) {
throw new SyntaxError('A default value for an argument must be a constant (a boolean, a string, a number, a sequence, or a mapping).', $token->getLine(), $stream->getSourceContext());
@@ -780,7 +768,7 @@ class ExpressionParser
if ($stream->test(Token::PUNCTUATION_TYPE, '(')) {
$arguments = $this->parseArguments(true);
} elseif ($test->hasOneMandatoryArgument()) {
- $arguments = new Nodes([0 => $this->parsePrimaryExpression()]);
+ $arguments = new Nodes([0 => $this->getPrimary()]);
}
if ('defined' === $test->getName() && $node instanceof NameExpression && null !== $alias = $this->parser->getImportedSymbol('function', $node->getAttribute('name'))) {
@@ -891,4 +879,14 @@ class ExpressionParser
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;
+ }
}
diff --git a/src/Node/Expression/ArrayExpression.php b/src/Node/Expression/ArrayExpression.php
index 912657150..86eb79ad7 100644
--- a/src/Node/Expression/ArrayExpression.php
+++ b/src/Node/Expression/ArrayExpression.php
@@ -83,10 +83,17 @@ class ArrayExpression extends AbstractExpression
}
$first = false;
- $key = $pair['key'] instanceof ConstantExpression ? $pair['key']->getAttribute('value') : null;
+ $key = null;
if ($pair['key'] instanceof NameExpression) {
$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 (\is_int($key)) {
diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php
index ec3c6ad4c..02d6baa93 100644
--- a/src/Node/Expression/GetAttrExpression.php
+++ b/src/Node/Expression/GetAttrExpression.php
@@ -18,6 +18,10 @@ use Twig\Template;
class GetAttrExpression extends AbstractExpression
{
+
+ /**
+ * @param ArrayExpression|NameExpression|null $arguments
+ */
public function __construct(AbstractExpression $node, AbstractExpression $attribute, ?AbstractExpression $arguments, string $type, int $lineno)
{
$nodes = ['node' => $node, 'attribute' => $attribute];
@@ -25,6 +29,10 @@ class GetAttrExpression extends AbstractExpression
$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);
}
diff --git a/src/Node/Expression/MethodCallExpression.php b/src/Node/Expression/MethodCallExpression.php
index 01806f91d..1b337960d 100644
--- a/src/Node/Expression/MethodCallExpression.php
+++ b/src/Node/Expression/MethodCallExpression.php
@@ -43,21 +43,9 @@ class MethodCallExpression extends AbstractExpression
->repr($this->getNode('node')->getAttribute('name'))
->raw('], ')
->repr($this->getAttribute('method'))
- ->raw(', [')
- ;
- $first = true;
- /** @var ArrayExpression */
- $args = $this->getNode('arguments');
- foreach ($args->getKeyValuePairs() as $pair) {
- if (!$first) {
- $compiler->raw(', ');
- }
- $first = false;
-
- $compiler->subcompile($pair['value']);
- }
- $compiler
- ->raw('], ')
+ ->raw(', ')
+ ->subcompile($this->getNode('arguments'))
+ ->raw(', ')
->repr($this->getTemplateLine())
->raw(', $context, $this->getSourceContext())');
}
diff --git a/src/Node/Expression/TempNameExpression.php b/src/Node/Expression/TempNameExpression.php
index 004c704a5..c1f091656 100644
--- a/src/Node/Expression/TempNameExpression.php
+++ b/src/Node/Expression/TempNameExpression.php
@@ -15,17 +15,21 @@ use Twig\Compiler;
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);
}
public function compile(Compiler $compiler): void
{
- $compiler
- ->raw('$_')
- ->raw($this->getAttribute('name'))
- ->raw('_')
- ;
+ $compiler->raw('$'.$this->getAttribute('name'));
}
}
diff --git a/src/Node/MacroNode.php b/src/Node/MacroNode.php
index 7c9d64b4c..2a895423e 100644
--- a/src/Node/MacroNode.php
+++ b/src/Node/MacroNode.php
@@ -14,6 +14,7 @@ namespace Twig\Node;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
use Twig\Error\SyntaxError;
+use Twig\Node\Expression\TempNameExpression;
/**
* Represents a macro node.
@@ -31,6 +32,10 @@ class MacroNode extends Node
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());
}
+ if (in_array($argumentName, TempNameExpression::RESERVED_NAMES)) {
+ $arguments->setNode('_'.$argumentName.'_', $argument);
+ $arguments->removeNode($argumentName);
+ }
}
parent::__construct(['body' => $body, 'arguments' => $arguments], ['name' => $name], $lineno);
@@ -47,7 +52,7 @@ class MacroNode extends Node
$pos = 0;
foreach ($this->getNode('arguments') as $name => $default) {
$compiler
- ->raw('$__'.$name.'__ = ')
+ ->raw('$'.$name.' = ')
->subcompile($default)
;
@@ -61,7 +66,7 @@ class MacroNode extends Node
}
$compiler
- ->raw('...$__varargs__')
+ ->raw('...$varargs')
->raw(")\n")
->write("{\n")
->indent()
@@ -73,8 +78,8 @@ class MacroNode extends Node
foreach ($this->getNode('arguments') as $name => $default) {
$compiler
->write('')
- ->string($name)
- ->raw(' => $__'.$name.'__')
+ ->string(trim($name, '_'))
+ ->raw(' => $'.$name)
->raw(",\n")
;
}
@@ -85,7 +90,7 @@ class MacroNode extends Node
->write('')
->string(self::VARARGS_NAME)
->raw(' => ')
- ->raw("\$__varargs__,\n")
+ ->raw("\$varargs,\n")
->outdent()
->write("] + \$this->env->getGlobals();\n\n")
->write("\$blocks = [];\n\n")
diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php
index d318ef7af..3f70f7bc5 100644
--- a/tests/ExpressionParserTest.php
+++ b/tests/ExpressionParserTest.php
@@ -261,24 +261,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()
{
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
diff --git a/tests/Fixtures/expressions/dynamic_attribute.test b/tests/Fixtures/expressions/dynamic_attribute.test
index 930c6f174..93731076a 100644
--- a/tests/Fixtures/expressions/dynamic_attribute.test
+++ b/tests/Fixtures/expressions/dynamic_attribute.test
@@ -4,6 +4,9 @@
{{ obj.(method) }}
{{ array.(item) }}
{{ 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.(method) is defined ? 'ok' : 'ko' }}
{{ obj.(nonmethod) is defined ? 'ok' : 'ko' }}
@@ -14,5 +17,8 @@ foo
bar
bar_a-b
bar_a-b
+bar_a-b
+bar_a-b
+bar_a-b
ok
ko
diff --git a/tests/Fixtures/expressions/method_call.test b/tests/Fixtures/expressions/method_call.test
index bf49f389e..ee700f80f 100644
--- a/tests/Fixtures/expressions/method_call.test
+++ b/tests/Fixtures/expressions/method_call.test
@@ -6,6 +6,9 @@ Twig supports method calls
{{ items.foo.bar }}
{{ items.foo['bar'] }}
{{ 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.self.foo() }}
{{ items.foo.is }}
@@ -20,6 +23,9 @@ foo
foo
bar
+bar_a-43
+bar_a-43
+bar_a-43
bar_a-43
bar_bar
foo
diff --git a/tests/Fixtures/tags/macro/named_arguments.test b/tests/Fixtures/tags/macro/named_arguments.test
new file mode 100644
index 000000000..58bd15b20
--- /dev/null
+++ b/tests/Fixtures/tags/macro/named_arguments.test
@@ -0,0 +1,14 @@
+--TEST--
+"macro" tag
+--TEMPLATE--
+{% import _self as forms %}
+
+{{ forms.input(size: 10, name: 'username') }}
+
+{% macro input(name, value, type, size) %}
+
+{% endmacro %}
+--DATA--
+return []
+--EXPECT--
+
diff --git a/tests/Node/MacroTest.php b/tests/Node/MacroTest.php
index c785cd3bc..97a988899 100644
--- a/tests/Node/MacroTest.php
+++ b/tests/Node/MacroTest.php
@@ -46,13 +46,13 @@ class MacroTest extends NodeTestCase
yield 'with use_yield = true' => [$node, <<macros;
\$context = [
- "foo" => \$__foo__,
- "bar" => \$__bar__,
- "varargs" => \$__varargs__,
+ "foo" => \$foo,
+ "bar" => \$bar,
+ "varargs" => \$varargs,
] + \$this->env->getGlobals();
\$blocks = [];
@@ -68,13 +68,13 @@ EOF
yield 'with use_yield = false' => [$node, <<macros;
\$context = [
- "foo" => \$__foo__,
- "bar" => \$__bar__,
- "varargs" => \$__varargs__,
+ "foo" => \$foo,
+ "bar" => \$bar,
+ "varargs" => \$varargs,
] + \$this->env->getGlobals();
\$blocks = [];