Allow calling a macro with a dynamic name via the dot operator

This commit is contained in:
Fabien Potencier
2026-06-06 12:32:52 +02:00
parent fa33c2278e
commit 87093aab9e
10 changed files with 211 additions and 15 deletions
+1
View File
@@ -1,6 +1,7 @@
# 3.28.0 (2026-XX-XX)
* Reduce memory usage and speed up the context restoration compiled at the end of `for` loops
* Allow calling a macro with a dynamic name via the dot operator (`macros.(name)(args)`)
* Report the column number in syntax errors and expose it via `Error::getTemplateColumn()`
* Track the source offset of each token and expose it via `Token::getOffset()`
* Fix nested `block()` calls to resolve against the overriding template when a block rendered through `block(name, template)` calls `parent()`
+13
View File
@@ -64,6 +64,19 @@ The macros can then be called at will in the *current* template:
{# You can also use named arguments #}
<p>{{ forms.input(name: 'password', type: 'password') }}</p>
The macro name can also be dynamic by wrapping an expression with parenthesis
after the :ref:`dot operator <dot_operator>`:
.. code-block:: html+twig
{% set field = 'input' %}
<p>{{ forms.(field)('username') }}</p>
<p>{{ forms.('text' ~ 'area')('comment') }}</p>
.. versionadded:: 3.28
Support for calling a macro with a dynamic name was added in Twig 3.28.
Alternatively you can import names from the template into the current namespace
via the ``from`` tag:
@@ -65,19 +65,25 @@ final class DotExpressionParser extends AbstractExpressionParser implements Infi
$arguments = $this->parseCallableArguments($parser, $token->getLine());
}
if (
$expr instanceof NameExpression
&& $attribute instanceof ConstantExpression
&& \is_string($name = $attribute->getAttribute('value'))
&& preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#D', $name)
$isMacroTarget = $expr instanceof NameExpression
&& (
null !== $parser->getImportedSymbol('template', $expr->getAttribute('name'))
|| '_self' === $expr->getAttribute('name')
)
);
if (
$isMacroTarget
&& $attribute instanceof ConstantExpression
&& \is_string($name = $attribute->getAttribute('value'))
&& preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#D', $name)
) {
return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), 'macro_'.$name, $arguments, $expr->getTemplateLine());
}
if ($isMacroTarget && !$attribute instanceof ConstantExpression) {
return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), $attribute, $arguments, $expr->getTemplateLine());
}
return new GetAttrExpression($expr, $attribute, $arguments, $type, $lineno, $nullSafe);
}
@@ -12,6 +12,7 @@
namespace Twig\Node\Expression;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\Variable\TemplateVariable;
/**
@@ -19,13 +20,22 @@ use Twig\Node\Expression\Variable\TemplateVariable;
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class MacroReferenceExpression extends AbstractExpression implements SupportDefinedTestInterface
class MacroReferenceExpression extends AbstractExpression implements SupportDefinedTestInterface, CoercesChildrenToStringInterface
{
use SupportDefinedTestDeprecationTrait;
use SupportDefinedTestTrait;
public function __construct(TemplateVariable $template, string $name, AbstractExpression $arguments, int $lineno)
/**
* @param string|AbstractExpression $name A static macro method name (e.g. "macro_foo") or, for a dynamic
* call, an expression resolving to the macro name (without the
* "macro_" prefix, which is added at runtime)
*/
public function __construct(TemplateVariable $template, string|AbstractExpression $name, AbstractExpression $arguments, int $lineno)
{
$nodes = ['template' => $template, 'arguments' => $arguments];
$attributes = ['name' => null];
if (\is_string($name)) {
// The name is emitted as raw PHP in compile() via "->{$name}(...)",
// so it must be a valid PHP method identifier. Reject anything else
// as a defense-in-depth against accidental PHP code injection from
@@ -33,8 +43,12 @@ class MacroReferenceExpression extends AbstractExpression implements SupportDefi
if (!preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#D', $name)) {
throw new \LogicException(\sprintf('Macro name "%s" is not a valid PHP identifier.', $name));
}
$attributes['name'] = $name;
} else {
$nodes['name'] = $name;
}
parent::__construct(['template' => $template, 'arguments' => $arguments], ['name' => $name], $lineno);
parent::__construct($nodes, $attributes, $lineno);
}
public function __clone()
@@ -49,6 +63,12 @@ class MacroReferenceExpression extends AbstractExpression implements SupportDefi
public function compile(Compiler $compiler): void
{
if ($this->hasNode('name')) {
$this->compileDynamic($compiler);
return;
}
if ($this->definedTest) {
$compiler
->subcompile($this->getNode('template'))
@@ -74,4 +94,43 @@ class MacroReferenceExpression extends AbstractExpression implements SupportDefi
->raw(')')
;
}
public function getStringCoercedChildNames(): array
{
// Dynamic macro names are prefixed via PHP string concatenation at runtime.
return $this->hasNode('name') ? ['name'] : [];
}
private function compileDynamic(Compiler $compiler): void
{
// The macro method name is resolved at runtime from a context value;
// prefixing it with "macro_" constrains the dynamic method call to the
// template's macro methods only, and getTemplateForMacro()/hasMacro()
// validate that the method actually exists.
$var = $compiler->getVarName();
if ($this->definedTest) {
$compiler
->subcompile($this->getNode('template'))
->raw('->hasMacro(\'macro_\'.')
->subcompile($this->getNode('name'))
->raw(', $context)')
;
return;
}
$compiler
->subcompile($this->getNode('template'))
->raw(\sprintf('->getTemplateForMacro($%s = \'macro_\'.', $var))
->subcompile($this->getNode('name'))
->raw(', $context, ')
->repr($this->getTemplateLine())
->raw(', $this->getSourceContext())')
->raw(\sprintf('->{$%s}', $var))
->raw('(...')
->subcompile($this->getNode('arguments'))
->raw(')')
;
}
}
+17
View File
@@ -699,6 +699,23 @@ class SandboxTest extends TestCase
}
}
public function testSandboxBlocksToStringOnDynamicMacroName()
{
$twig = $this->getEnvironment(true, [], ['index' => <<<EOF
{% import _self as macros %}
{% macro foo() %}foo{% endmacro %}
{{ macros.(obj)() }}
EOF
], ['import', 'macro']);
try {
$twig->load('index')->render(self::$params);
$this->fail('Sandbox throws a SecurityError exception if __toString is called on a dynamic macro name');
} catch (SecurityNotAllowedMethodError $e) {
$this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName());
$this->assertEquals('__tostring', $e->getMethodName());
}
}
public function testSandboxBlocksToStringOnIncludeTemplateName()
{
$twig = $this->getEnvironment(true, [], ['index' => '{% include obj %}'], ['include']);
+21
View File
@@ -0,0 +1,21 @@
--TEST--
macro called with a dynamic name using the dot operator
--TEMPLATE--
{% import _self as test %}
{% macro element1(data) -%}
element1: {{ data }}
{%- endmacro %}
{% macro element2(data) -%}
element2: {{ data }}
{%- endmacro %}
{% set name = 'element1' %}
{{ test.(name)('foo') }}
{{ test.('element' ~ 2)('bar') }}
--DATA--
return []
--EXPECT--
element1: foo
element2: bar
@@ -0,0 +1,20 @@
--TEST--
nested macro calls with dynamic names use distinct compiled variables
--TEMPLATE--
{% import _self as m %}
{% macro wrap(x) -%}
[{{ x }}]
{%- endmacro %}
{% macro value() -%}
V
{%- endmacro %}
{% set outer = 'wrap' %}
{% set inner = 'value' %}
{{ m.(outer)(m.(inner)()) }}
--DATA--
return []
--EXPECT--
[V]
@@ -0,0 +1,11 @@
--TEST--
macro called with a dynamic name that does not resolve to a known macro
--TEMPLATE--
{% import _self as macros %}
{% set name = 'unknown' %}
{{ macros.(name)() }}
--DATA--
return []
--EXCEPTION--
Twig\Error\RuntimeError: Macro "unknown" is not defined in template "index.twig" in "index.twig" at line 5.
@@ -0,0 +1,20 @@
--TEST--
"defined" support for macros called with a dynamic name
--TEMPLATE--
{% import _self as macros %}
{% set known = 'hello' %}
{% set unknown = 'missing' %}
{{~ macros.(known) is defined ? 'OK' : 'KO' }}
{{~ macros.(known)() is defined ? 'OK' : 'KO' }}
{{~ macros.(unknown) is not defined ? 'OK' : 'KO' }}
{{~ macros.(unknown)() is not defined ? 'OK' : 'KO' }}
{% macro hello(name) %}{% endmacro %}
--DATA--
return []
--EXPECT--
OK
OK
OK
OK
@@ -13,8 +13,11 @@ namespace Twig\Tests\Node\Expression;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\MacroReferenceExpression;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Node\Expression\Variable\TemplateVariable;
class MacroReferenceTest extends TestCase
@@ -40,4 +43,29 @@ class MacroReferenceTest extends TestCase
yield 'PHP injection payload' => ['macro_foo + 1; trigger_error("BAD") //'];
yield 'contains NUL byte' => ["foo\x00bar"];
}
public function testConstructorAcceptsAnExpressionAsName()
{
$node = new MacroReferenceExpression(new TemplateVariable('foo', 1), new ContextVariable('name', 1), new ArrayExpression([], 1), 1);
$this->assertTrue($node->hasNode('name'));
$this->assertNull($node->getAttribute('name'));
}
public function testDynamicNamePrefixesMacroAtRuntime()
{
$env = new Environment(new ArrayLoader());
$compiler = new \Twig\Compiler($env);
$node = new MacroReferenceExpression(
new TemplateVariable('mac', 1),
new ContextVariable('name', 1),
new ArrayExpression([], 1),
1,
);
$compiler->compile($node);
$this->assertStringContainsString("getTemplateForMacro(\$_v0 = 'macro_'.", $compiler->getSource());
$this->assertStringContainsString('->{$_v0}(...', $compiler->getSource());
}
}