Skip to content

Commit e486d71

Browse files
committed
Redesign macro calls and argument handling
1 parent 6a175a5 commit e486d71

37 files changed

Lines changed: 1064 additions & 198 deletions

CHANGELOG

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
* Render backed enums using their backing value in the `html_attr` function
44
* Fix empty Markup values being treated as truthy in and, or, xor, not, ternary, and elvis expressions
55
* Fix a PHP 8.5 `chr()` deprecation when decoding an octal string escape sequence larger than `\377` (such as `"\777"`)
6+
* Deprecate calling a macro with a name whose case differs from its definition; macro names will be case-sensitive in 4.0
7+
* Deprecate calling a macro without a value for an argument that has no default value; the argument will be required in 4.0
8+
* Deprecate passing extra or unknown arguments to a macro that does not declare a variadic argument; it will throw in 4.0
9+
* Add support for declaring an explicit variadic macro argument (`{% macro foo(a, ...rest) %}`)
10+
* Compile macros as closures stored in a per-template registry instead of `macro_`-prefixed PHP methods
11+
* Mark `Twig\Node\MacroNode` as `@final`; it will be final in Twig 4.0
12+
* Change `MacroReferenceExpression` to take the bare macro name instead of a `macro_`-prefixed method name
613
* Mark `Twig\Markup` as `@final`; it will be final in Twig 4.0
714
* Reduce memory usage and speed up the context restoration compiled at the end of `for` loops
815
* Allow calling a macro with a dynamic name via the dot operator (`macros.(name)(args)`)

doc/deprecated.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ Classes
1111
* The ``Twig\Markup`` class is considered final as of Twig 3.28 and will be
1212
final in Twig 4.0. Use ``Twig\Markup`` directly instead of extending it.
1313

14+
* The ``Twig\Node\MacroNode`` class is considered final as of Twig 3.28 and
15+
will be final in Twig 4.0.
16+
1417
Functions
1518
---------
1619

@@ -306,6 +309,26 @@ Templates
306309
deprecated as of Twig 3.27 and will throw in Twig 4.0. These tags have a
307310
global effect on the template and must be declared at the root of its body.
308311

312+
Macros
313+
------
314+
315+
* Passing more arguments to a macro than it declares is deprecated as of Twig
316+
3.28 and will throw in Twig 4.0. Declare an explicit variadic argument
317+
(``{% macro foo(a, ...rest) %}``) to accept extra positional and named
318+
arguments instead of relying on the implicit ``varargs`` variable.
319+
320+
* Passing an unknown named argument to a macro is deprecated as of Twig 3.28 and
321+
will throw in Twig 4.0. Declare an explicit variadic argument to accept it.
322+
323+
* Calling a macro without a value for an argument that has no default value is
324+
deprecated as of Twig 3.28; such an argument will be required in Twig 4.0
325+
(today it silently defaults to ``null``). To keep an argument optional, give
326+
it an explicit default value (e.g. ``{% macro input(name, value = null) %}``).
327+
328+
* Calling a macro with a name whose case differs from its definition (e.g.
329+
calling ``input`` as ``INPUT``) is deprecated as of Twig 3.28; macro names
330+
will be case-sensitive in Twig 4.0. Use the name exactly as defined.
331+
309332
Filters
310333
-------
311334

doc/tags/macro.rst

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,62 @@ via macros (called ``forms.twig``):
1111

1212
.. code-block:: html+twig
1313

14-
{% macro input(name, value, type = "text", size = 20) %}
15-
<input type="{{ type }}" name="{{ name }}" value="{{ value|e }}" size="{{ size }}"/>
14+
{% macro input(name, value = "", type = "text", size = 20) %}
15+
<input
16+
type="{{ type }}"
17+
name="{{ name }}"
18+
value="{{ value|e }}"
19+
size="{{ size }}"
20+
/>
1621
{% endmacro %}
1722

18-
{% macro textarea(name, value, rows = 10, cols = 40) %}
19-
<textarea name="{{ name }}" rows="{{ rows }}" cols="{{ cols }}">{{ value|e }}</textarea>
23+
{% macro textarea(name, value = "", rows = 10, cols = 40) %}
24+
<textarea
25+
name="{{ name }}"
26+
rows="{{ rows }}"
27+
cols="{{ cols }}"
28+
>{{ value|e }}</textarea>
2029
{% endmacro %}
2130

22-
Each macro argument can have a default value (here ``text`` is the default value
31+
A macro argument can have a default value (here ``text`` is the default value
2332
for ``type`` if not provided in the call).
2433

25-
Macros differ from native PHP functions in a few ways:
34+
As with PHP function arguments, a macro argument is required unless it declares
35+
a default value. Here, ``name`` is required while ``value``, ``type``, and
36+
``size`` are optional.
2637

27-
* Arguments of a macro are always optional.
38+
.. deprecated:: 3.28
2839

29-
* If extra positional arguments are passed to a macro, they end up in the
30-
special ``varargs`` variable as a list of values.
40+
Calling a macro without a value for an argument that has no default value is
41+
deprecated as of Twig 3.28; the argument will be required in Twig 4.0 (until
42+
then, it defaults to ``null``). Give every optional argument an explicit
43+
default value.
3144

32-
But as with PHP functions, macros don't have access to the current template
33-
variables.
45+
To accept an arbitrary number of extra arguments, declare an explicit variadic
46+
argument as described below.
47+
48+
Note that macros don't have access to the current template variables.
49+
50+
A macro can declare an explicit variadic argument to collect any extra
51+
positional and named arguments into a named variable, using the same ``...``
52+
notation as PHP:
53+
54+
.. code-block:: html+twig
55+
56+
{% macro tag(element, ...attributes) %}
57+
<{{ element }}
58+
{%- for key, value in attributes %} {{ key }}="{{ value }}"{% endfor -%}
59+
>
60+
{% endmacro %}
61+
62+
{{ _self.tag("input", type: "text", name: "username") }}
63+
64+
The variadic argument must be the last one and cannot have a default value.
65+
66+
.. versionadded:: 3.28
67+
68+
Support for declaring an explicit variadic macro argument was added in Twig
69+
3.28.
3470

3571
.. tip::
3672

@@ -110,8 +146,13 @@ via the ``from`` tag:
110146

111147
<p>{{ _self.input('password', '', 'password') }}</p>
112148

113-
{% macro input(name, value, type = "text", size = 20) %}
114-
<input type="{{ type }}" name="{{ name }}" value="{{ value|e }}" size="{{ size }}"/>
149+
{% macro input(name, value = "", type = "text", size = 20) %}
150+
<input
151+
type="{{ type }}"
152+
name="{{ name }}"
153+
value="{{ value|e }}"
154+
size="{{ size }}"
155+
/>
115156
{% endmacro %}
116157

117158
Macros Scoping
@@ -162,3 +203,16 @@ readability (the name after the ``endmacro`` word must match the macro name):
162203
{% macro input() %}
163204
...
164205
{% endmacro input %}
206+
207+
Deprecating a Macro
208+
-------------------
209+
210+
Use the :doc:`deprecated <deprecated>` tag at the top of a macro to deprecate
211+
it; a deprecation notice is triggered whenever the macro is called:
212+
213+
.. code-block:: html+twig
214+
215+
{% macro input(name, value = "") %}
216+
{% deprecated 'The "input" macro is deprecated, use "field" instead.' %}
217+
<input name="{{ name }}" value="{{ value|e }}"/>
218+
{% endmacro %}

src/ExpressionParser/Infix/ArgumentsTrait.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Twig\Error\SyntaxError;
1515
use Twig\Node\Expression\ArrayExpression;
1616
use Twig\Node\Expression\Binary\SetBinary;
17+
use Twig\Node\Expression\ConstantExpression;
1718
use Twig\Node\Expression\Unary\SpreadUnary;
1819
use Twig\Node\Expression\Variable\ContextVariable;
1920
use Twig\Node\Expression\Variable\LocalVariable;
@@ -23,11 +24,11 @@
2324

2425
trait ArgumentsTrait
2526
{
26-
private function parseCallableArguments(Parser $parser, int $line, bool $parseOpenParenthesis = true): ArrayExpression
27+
private function parseCallableArguments(Parser $parser, int $line, bool $parseOpenParenthesis = true, bool $preserveNames = false): ArrayExpression
2728
{
2829
$arguments = new ArrayExpression([], $line);
2930
foreach ($this->parseNamedArguments($parser, $parseOpenParenthesis) as $k => $n) {
30-
$arguments->addElement($n, new LocalVariable($k, $line));
31+
$arguments->addElement($n, \is_int($k) || !$preserveNames ? new LocalVariable($k, $line) : new ConstantExpression($k, $line));
3132
}
3233

3334
return $arguments;

src/ExpressionParser/Infix/DotExpressionParser.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,24 +60,24 @@ public function parse(Parser $parser, AbstractExpression $expr, Token $token): A
6060
}
6161
}
6262

63-
if ($stream->test(Token::OPERATOR_TYPE, '(')) {
64-
$type = Template::METHOD_CALL;
65-
$arguments = $this->parseCallableArguments($parser, $token->getLine());
66-
}
67-
6863
$isMacroTarget = $expr instanceof NameExpression
6964
&& (
7065
null !== $parser->getImportedSymbol('template', $expr->getAttribute('name'))
7166
|| '_self' === $expr->getAttribute('name')
7267
);
7368

69+
if ($stream->test(Token::OPERATOR_TYPE, '(')) {
70+
$type = Template::METHOD_CALL;
71+
$arguments = $this->parseCallableArguments($parser, $token->getLine(), preserveNames: $isMacroTarget);
72+
}
73+
7474
if (
7575
$isMacroTarget
7676
&& $attribute instanceof ConstantExpression
7777
&& \is_string($name = $attribute->getAttribute('value'))
7878
&& preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#D', $name)
7979
) {
80-
return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), 'macro_'.$name, $arguments, $expr->getTemplateLine());
80+
return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), $name, $arguments, $expr->getTemplateLine());
8181
}
8282

8383
if ($isMacroTarget && !$attribute instanceof ConstantExpression) {

src/ExpressionParser/Infix/FunctionExpressionParser.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,12 @@ public function parse(Parser $parser, AbstractExpression $expr, Token $token): A
4242

4343
$name = $expr->getAttribute('name');
4444

45+
// To be removed in 4.0 ("from -> import" unification, see FromTokenParser):
46+
// a bare call to a macro imported via "from" is resolved here through the
47+
// dedicated "function" imported symbol. In 4.0 this branch goes away once
48+
// "from" registers a regular "template" import symbol like "import" does.
4549
if (null !== $alias = $parser->getImportedSymbol('function', $name)) {
46-
return new MacroReferenceExpression($alias['node']->getNode('var'), $alias['name'], $this->parseCallableArguments($parser, $line, false), $line);
50+
return new MacroReferenceExpression($alias['node']->getNode('var'), $alias['name'], $this->parseCallableArguments($parser, $line, false, true), $line);
4751
}
4852

4953
$args = $this->parseNamedArguments($parser, false);

src/Extension/CoreExtension.php

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,18 +1360,7 @@ public static function capitalize(string $charset, $string): string
13601360
*/
13611361
public static function callMacro(Template $template, string $method, array $args, int $lineno, array $context, Source $source)
13621362
{
1363-
if (!method_exists($template, $method)) {
1364-
$parent = $template;
1365-
while ($parent = $parent->getParent($context)) {
1366-
if (method_exists($parent, $method)) {
1367-
return $parent->$method(...$args);
1368-
}
1369-
}
1370-
1371-
throw new RuntimeError(\sprintf('Macro "%s" is not defined in template "%s".', substr($method, \strlen('macro_')), $template->getTemplateName()), $lineno, $source);
1372-
}
1373-
1374-
return $template->$method(...$args);
1363+
return $template->callMacro(substr($method, \strlen('macro_')), $args, $context, $lineno, $source);
13751364
}
13761365

13771366
/**

src/Node/Expression/MacroReferenceExpression.php

Lines changed: 18 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,15 @@ class MacroReferenceExpression extends AbstractExpression implements SupportDefi
2626
use SupportDefinedTestTrait;
2727

2828
/**
29-
* @param string|AbstractExpression $name A static macro method name (e.g. "macro_foo") or, for a dynamic
30-
* call, an expression resolving to the macro name (without the
31-
* "macro_" prefix, which is added at runtime)
29+
* @param string|AbstractExpression $name The bare macro name (a static identifier) or, for a dynamic
30+
* call, an expression resolving to the macro name
3231
*/
3332
public function __construct(TemplateVariable $template, string|AbstractExpression $name, AbstractExpression $arguments, int $lineno)
3433
{
3534
$nodes = ['template' => $template, 'arguments' => $arguments];
3635
$attributes = ['name' => null];
3736

3837
if (\is_string($name)) {
39-
// The name is emitted as raw PHP in compile() via "->{$name}(...)",
40-
// so it must be a valid PHP method identifier. Reject anything else
41-
// as a defense-in-depth against accidental PHP code injection from
42-
// a caller that forgot to validate user-controlled input.
43-
if (!preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#D', $name)) {
44-
throw new \LogicException(\sprintf('Macro name "%s" is not a valid PHP identifier.', $name));
45-
}
4638
$attributes['name'] = $name;
4739
} else {
4840
$nodes['name'] = $name;
@@ -63,74 +55,41 @@ public function __clone()
6355

6456
public function compile(Compiler $compiler): void
6557
{
66-
if ($this->hasNode('name')) {
67-
$this->compileDynamic($compiler);
68-
69-
return;
70-
}
58+
$compiler->subcompile($this->getNode('template'));
7159

7260
if ($this->definedTest) {
73-
$compiler
74-
->subcompile($this->getNode('template'))
75-
->raw('->hasMacro(')
76-
->repr($this->getAttribute('name'))
77-
->raw(', $context')
78-
->raw(')')
79-
;
61+
$compiler->raw('->hasMacro(');
62+
$this->compileName($compiler);
63+
$compiler->raw(', $context)');
8064

8165
return;
8266
}
8367

68+
$compiler->raw('->callMacro(');
69+
$this->compileName($compiler);
8470
$compiler
85-
->subcompile($this->getNode('template'))
86-
->raw('->getTemplateForMacro(')
87-
->repr($this->getAttribute('name'))
71+
->raw(', ')
72+
->subcompile($this->getNode('arguments'))
8873
->raw(', $context, ')
8974
->repr($this->getTemplateLine())
9075
->raw(', $this->getSourceContext())')
91-
->raw(\sprintf('->%s', $this->getAttribute('name')))
92-
->raw('(...')
93-
->subcompile($this->getNode('arguments'))
94-
->raw(')')
9576
;
9677
}
9778

9879
public function getStringCoercedChildNames(): array
9980
{
100-
// Dynamic macro names are prefixed via PHP string concatenation at runtime.
81+
// Dynamic macro names are string-coerced at runtime.
10182
return $this->hasNode('name') ? ['name'] : [];
10283
}
10384

104-
private function compileDynamic(Compiler $compiler): void
85+
private function compileName(Compiler $compiler): void
10586
{
106-
// The macro method name is resolved at runtime from a context value;
107-
// prefixing it with "macro_" constrains the dynamic method call to the
108-
// template's macro methods only, and getTemplateForMacro()/hasMacro()
109-
// validate that the method actually exists.
110-
$var = $compiler->getVarName();
111-
112-
if ($this->definedTest) {
113-
$compiler
114-
->subcompile($this->getNode('template'))
115-
->raw('->hasMacro(\'macro_\'.')
116-
->subcompile($this->getNode('name'))
117-
->raw(', $context)')
118-
;
119-
120-
return;
87+
// A dynamic macro name is resolved at runtime from a context value and
88+
// string-coerced before the registry lookup.
89+
if ($this->hasNode('name')) {
90+
$compiler->raw('(string) ')->subcompile($this->getNode('name'));
91+
} else {
92+
$compiler->repr($this->getAttribute('name'));
12193
}
122-
123-
$compiler
124-
->subcompile($this->getNode('template'))
125-
->raw(\sprintf('->getTemplateForMacro($%s = \'macro_\'.', $var))
126-
->subcompile($this->getNode('name'))
127-
->raw(', $context, ')
128-
->repr($this->getTemplateLine())
129-
->raw(', $this->getSourceContext())')
130-
->raw(\sprintf('->{$%s}', $var))
131-
->raw('(...')
132-
->subcompile($this->getNode('arguments'))
133-
->raw(')')
134-
;
13594
}
13695
}

src/Node/Expression/MethodCallExpression.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ public function compile(Compiler $compiler): void
3434
{
3535
if ($this->definedTest) {
3636
$compiler
37-
->raw('method_exists($macros[')
37+
->raw('$macros[')
3838
->repr($this->getNode('node')->getAttribute('name'))
39-
->raw('], ')
40-
->repr($this->getAttribute('method'))
41-
->raw(')')
39+
->raw(']->hasMacro(')
40+
->repr(substr($this->getAttribute('method'), \strlen('macro_')))
41+
->raw(', $context)')
4242
;
4343

4444
return;

src/Node/Expression/TempNameExpression.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
class TempNameExpression extends AbstractExpression
1818
{
19+
// 4.0: re-evaluate "varargs" here once the implicit macro varargs bucket is removed
20+
// (see MacroNode::VARARGS_NAME); the other names map to compiled variables ($context,
21+
// $macros, $blocks, $this) and must stay.
1922
public const RESERVED_NAMES = ['varargs', 'context', 'macros', 'blocks', 'this'];
2023

2124
public function __construct(string|int|null $name, int $lineno)

0 commit comments

Comments
 (0)