From 223d36bbae72768d3f21510c0ee9815f4c0e5b9d Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Thu, 10 Oct 2024 10:43:40 +0200 Subject: [PATCH] Add support for named arguments on macro calls and dot operator arguments --- CHANGELOG | 2 ++ doc/tags/macro.rst | 7 ++-- doc/templates.rst | 33 ++++++++++++++++--- src/ExpressionParser.php | 28 +++++++++------- src/Node/Expression/ArrayExpression.php | 9 ++++- src/Node/Expression/GetAttrExpression.php | 8 +++++ src/Node/Expression/MethodCallExpression.php | 18 ++-------- src/Node/Expression/TempNameExpression.php | 16 +++++---- src/Node/MacroNode.php | 17 ++++++---- tests/ExpressionParserTest.php | 18 ---------- .../expressions/dynamic_attribute.test | 6 ++++ tests/Fixtures/expressions/method_call.test | 6 ++++ .../Fixtures/tags/macro/named_arguments.test | 14 ++++++++ tests/Node/MacroTest.php | 16 ++++----- 14 files changed, 127 insertions(+), 71 deletions(-) create mode 100644 tests/Fixtures/tags/macro/named_arguments.test diff --git a/CHANGELOG b/CHANGELOG index ef500e8aa..5b07b5955 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # 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 * Allow arrow functions everywhere * Deprecate passing a string or an array to Twig callable arguments accepting arrow functions (pass a `\Closure`) 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 54f26c4b6..4fbbe2fec 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. .. 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. 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 ``()``. @@ -900,6 +909,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 @@ -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, 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 56d6d4df5..ddfbd2a47 100644 --- a/src/ExpressionParser.php +++ b/src/ExpressionParser.php @@ -26,6 +26,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; @@ -530,11 +531,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); @@ -575,9 +572,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); @@ -594,9 +589,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()); @@ -708,6 +701,9 @@ class ExpressionParser if (func_num_args() > 2) { 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 = []; $stream = $this->parser->getStream(); @@ -949,4 +945,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 6c6efee13..9769b719e 100644 --- a/src/Node/Expression/ArrayExpression.php +++ b/src/Node/Expression/ArrayExpression.php @@ -98,10 +98,17 @@ class ArrayExpression extends AbstractExpression $compiler->raw('...')->subcompile($pair['value']); ++$nextIndex; } else { - $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 29a446b88..571f6aea1 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 5a2543a9f..f3120dbd0 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,13 +32,17 @@ class MacroNode extends Node public function __construct(string $name, Node $body, Node $arguments, int $lineno) { 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) { 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); @@ -54,7 +59,7 @@ class MacroNode extends Node $pos = 0; foreach ($this->getNode('arguments') as $name => $default) { $compiler - ->raw('$__'.$name.'__ = ') + ->raw('$'.$name.' = ') ->subcompile($default) ; @@ -68,7 +73,7 @@ class MacroNode extends Node } $compiler - ->raw('...$__varargs__') + ->raw('...$varargs') ->raw(")\n") ->write("{\n") ->indent() @@ -80,8 +85,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") ; } @@ -92,7 +97,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 82ff47d44..def632c19 100644 --- a/tests/ExpressionParserTest.php +++ b/tests/ExpressionParserTest.php @@ -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() { $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 7321c7801..1902b73f0 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 = [];