mirror of
https://github.com/twigphp/Twig.git
synced 2026-08-17 09:42:31 +00:00
Allow calling a macro with a dynamic name via the dot operator
This commit is contained in:
@@ -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()`
|
||||
|
||||
@@ -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,22 +20,35 @@ 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)
|
||||
{
|
||||
// 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
|
||||
// a caller that forgot to validate user-controlled input.
|
||||
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));
|
||||
$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
|
||||
// a caller that forgot to validate user-controlled input.
|
||||
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(')')
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user