diff --git a/CHANGELOG b/CHANGELOG
index c930ffc2be0..c8148f8293a 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,6 +1,14 @@
-# 3.28.1 (2026-XX-XX)
+# 3.29.0 (2026-XX-XX)
* Fix duplicated macro argument names triggering a PHP fatal error instead of a `SyntaxError`
+ * Deprecate calling or testing a macro with a name whose case differs from its definition; macro names will be case-sensitive in 4.0
+ * Deprecate calling a macro without a value for an argument that has no default value; the argument will be required in 4.0
+ * Deprecate passing extra or unknown arguments to a macro that does not declare a variadic argument; it will throw in 4.0
+ * Add support for declaring an explicit variadic macro argument (`{% macro foo(a, ...rest) %}`)
+ * Compile macros as closures stored in a per-template registry instead of `macro_`-prefixed PHP methods
+ * Mark `Twig\Node\MacroNode` as `@final`; it will be final in Twig 4.0
+ * Change `MacroReferenceExpression` to take the bare macro name instead of a `macro_`-prefixed method name
+ * Deprecate resolving a macro through a `macro_`-prefixed name; pass the bare macro name to `MacroReferenceExpression`
# 3.28.0 (2026-07-03)
diff --git a/doc/deprecated.rst b/doc/deprecated.rst
index d8e548b5787..9d0a2a842e8 100644
--- a/doc/deprecated.rst
+++ b/doc/deprecated.rst
@@ -11,6 +11,9 @@ Classes
* The ``Twig\Markup`` class is considered final as of Twig 3.28 and will be
final in Twig 4.0. Use ``Twig\Markup`` directly instead of extending it.
+* The ``Twig\Node\MacroNode`` class is considered final as of Twig 3.29 and
+ will be final in Twig 4.0.
+
Functions
---------
@@ -306,6 +309,32 @@ Templates
deprecated as of Twig 3.27 and will throw in Twig 4.0. These tags have a
global effect on the template and must be declared at the root of its body.
+Macros
+------
+
+* Passing more arguments to a macro than it declares is deprecated as of Twig
+ 3.29 and will throw in Twig 4.0. Declare an explicit variadic argument
+ (``{% macro foo(a, ...rest) %}``) to accept extra positional and named
+ arguments instead of relying on the implicit ``varargs`` variable.
+
+* Passing an unknown named argument to a macro is deprecated as of Twig 3.29 and
+ will throw in Twig 4.0. Declare an explicit variadic argument to accept it.
+
+* Calling a macro without a value for an argument that has no default value is
+ deprecated as of Twig 3.29; such an argument will be required in Twig 4.0
+ (today it silently defaults to ``null``). To keep an argument optional, give
+ it an explicit default value (e.g. ``{% macro input(name, value = null) %}``).
+
+* Calling a macro (or testing it with the ``defined`` test) with a name whose
+ case differs from its definition (e.g. calling ``input`` as ``INPUT``) is
+ deprecated as of Twig 3.29; macro names will be case-sensitive in Twig 4.0.
+ Use the name exactly as defined.
+
+* Resolving a macro through a ``macro_``-prefixed name (e.g. via a
+ ``Twig\Node\Expression\MacroReferenceExpression`` node built with
+ ``macro_input``) is deprecated as of Twig 3.29 and will not resolve in Twig
+ 4.0; pass the bare macro name instead.
+
Filters
-------
diff --git a/doc/tags/macro.rst b/doc/tags/macro.rst
index 3ebfdd86927..d79d93979ed 100644
--- a/doc/tags/macro.rst
+++ b/doc/tags/macro.rst
@@ -11,26 +11,62 @@ via macros (called ``forms.twig``):
.. code-block:: html+twig
- {% macro input(name, value, type = "text", size = 20) %}
-
+ {% macro input(name, value = "", type = "text", size = 20) %}
+
{% endmacro %}
- {% macro textarea(name, value, rows = 10, cols = 40) %}
-
+ {% macro textarea(name, value = "", rows = 10, cols = 40) %}
+
{% endmacro %}
-Each macro argument can have a default value (here ``text`` is the default value
+A macro argument can have a default value (here ``text`` is the default value
for ``type`` if not provided in the call).
-Macros differ from native PHP functions in a few ways:
+As with PHP function arguments, a macro argument is required unless it declares
+a default value. Here, ``name`` is required while ``value``, ``type``, and
+``size`` are optional.
-* Arguments of a macro are always optional.
+.. deprecated:: 3.29
-* If extra positional arguments are passed to a macro, they end up in the
- special ``varargs`` variable as a list of values.
+ Calling a macro without a value for an argument that has no default value is
+ deprecated as of Twig 3.29; the argument will be required in Twig 4.0 (until
+ then, it defaults to ``null``). Give every optional argument an explicit
+ default value.
-But as with PHP functions, macros don't have access to the current template
-variables.
+To accept an arbitrary number of extra arguments, declare an explicit variadic
+argument as described below.
+
+Note that macros don't have access to the current template variables.
+
+A macro can declare an explicit variadic argument to collect any extra
+positional and named arguments into a named variable, using the same ``...``
+notation as PHP:
+
+.. code-block:: html+twig
+
+ {% macro tag(element, ...attributes) %}
+ <{{ element }}
+ {%- for key, value in attributes %} {{ key }}="{{ value }}"{% endfor -%}
+ >
+ {% endmacro %}
+
+ {{ _self.tag("input", type: "text", name: "username") }}
+
+The variadic argument must be the last one and cannot have a default value.
+
+.. versionadded:: 3.29
+
+ Support for declaring an explicit variadic macro argument was added in Twig
+ 3.29.
.. tip::
@@ -110,8 +146,13 @@ via the ``from`` tag:
{{ _self.input('password', '', 'password') }}
- {% macro input(name, value, type = "text", size = 20) %}
-
+ {% macro input(name, value = "", type = "text", size = 20) %}
+
{% endmacro %}
Macros Scoping
@@ -162,3 +203,16 @@ readability (the name after the ``endmacro`` word must match the macro name):
{% macro input() %}
...
{% endmacro input %}
+
+Deprecating a Macro
+-------------------
+
+Use the :doc:`deprecated ` tag at the top of a macro to deprecate
+it; a deprecation notice is triggered whenever the macro is called:
+
+.. code-block:: html+twig
+
+ {% macro input(name, value = "") %}
+ {% deprecated 'The "input" macro is deprecated, use "field" instead.' %}
+
+ {% endmacro %}
diff --git a/src/Environment.php b/src/Environment.php
index 71459e2108e..05098f0b703 100644
--- a/src/Environment.php
+++ b/src/Environment.php
@@ -43,11 +43,11 @@
*/
class Environment
{
- public const VERSION = '3.28.1-DEV';
- public const VERSION_ID = 32801;
+ public const VERSION = '3.29.0-DEV';
+ public const VERSION_ID = 32900;
public const MAJOR_VERSION = 3;
- public const MINOR_VERSION = 28;
- public const RELEASE_VERSION = 1;
+ public const MINOR_VERSION = 29;
+ public const RELEASE_VERSION = 0;
public const EXTRA_VERSION = 'DEV';
private $charset;
diff --git a/src/ExpressionParser/Infix/ArgumentsTrait.php b/src/ExpressionParser/Infix/ArgumentsTrait.php
index 96bde555d3a..fe20138430a 100644
--- a/src/ExpressionParser/Infix/ArgumentsTrait.php
+++ b/src/ExpressionParser/Infix/ArgumentsTrait.php
@@ -14,6 +14,7 @@
use Twig\Error\SyntaxError;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\Binary\SetBinary;
+use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\Unary\SpreadUnary;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Node\Expression\Variable\LocalVariable;
@@ -23,11 +24,11 @@
trait ArgumentsTrait
{
- private function parseCallableArguments(Parser $parser, int $line, bool $parseOpenParenthesis = true): ArrayExpression
+ private function parseCallableArguments(Parser $parser, int $line, bool $parseOpenParenthesis = true, bool $preserveNames = false): ArrayExpression
{
$arguments = new ArrayExpression([], $line);
foreach ($this->parseNamedArguments($parser, $parseOpenParenthesis) as $k => $n) {
- $arguments->addElement($n, new LocalVariable($k, $line));
+ $arguments->addElement($n, \is_int($k) || !$preserveNames ? new LocalVariable($k, $line) : new ConstantExpression($k, $line));
}
return $arguments;
diff --git a/src/ExpressionParser/Infix/DotExpressionParser.php b/src/ExpressionParser/Infix/DotExpressionParser.php
index 287fbd18c4f..c2ae94b8ff1 100644
--- a/src/ExpressionParser/Infix/DotExpressionParser.php
+++ b/src/ExpressionParser/Infix/DotExpressionParser.php
@@ -60,28 +60,21 @@ public function parse(Parser $parser, AbstractExpression $expr, Token $token): A
}
}
- if ($stream->test(Token::OPERATOR_TYPE, '(')) {
- $type = Template::METHOD_CALL;
- $arguments = $this->parseCallableArguments($parser, $token->getLine());
- }
-
$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 ($stream->test(Token::OPERATOR_TYPE, '(')) {
+ $type = Template::METHOD_CALL;
+ $arguments = $this->parseCallableArguments($parser, $token->getLine(), preserveNames: $isMacroTarget);
}
- if ($isMacroTarget && !$attribute instanceof ConstantExpression) {
- return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), $attribute, $arguments, $expr->getTemplateLine());
+ if ($isMacroTarget) {
+ $name = $attribute instanceof ConstantExpression ? (string) $attribute->getAttribute('value') : $attribute;
+
+ return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), $name, $arguments, $expr->getTemplateLine());
}
return new GetAttrExpression($expr, $attribute, $arguments, $type, $lineno, $nullSafe);
diff --git a/src/ExpressionParser/Infix/FunctionExpressionParser.php b/src/ExpressionParser/Infix/FunctionExpressionParser.php
index c8851823d6f..ca2cce51f94 100644
--- a/src/ExpressionParser/Infix/FunctionExpressionParser.php
+++ b/src/ExpressionParser/Infix/FunctionExpressionParser.php
@@ -42,8 +42,12 @@ public function parse(Parser $parser, AbstractExpression $expr, Token $token): A
$name = $expr->getAttribute('name');
+ // A bare call to a macro imported via "from" is syntactically a function call;
+ // it is resolved through the "function" imported symbol registered by
+ // FromTokenParser, which maps the local alias to the macro name and the
+ // template it comes from.
if (null !== $alias = $parser->getImportedSymbol('function', $name)) {
- return new MacroReferenceExpression($alias['node']->getNode('var'), $alias['name'], $this->parseCallableArguments($parser, $line, false), $line);
+ return new MacroReferenceExpression($alias['node']->getNode('var'), $alias['name'], $this->parseCallableArguments($parser, $line, false, true), $line);
}
$args = $this->parseNamedArguments($parser, false);
diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php
index 9ef620b6a70..65f867b3540 100644
--- a/src/Extension/CoreExtension.php
+++ b/src/Extension/CoreExtension.php
@@ -1360,18 +1360,7 @@ public static function capitalize(string $charset, $string): string
*/
public static function callMacro(Template $template, string $method, array $args, int $lineno, array $context, Source $source)
{
- if (!method_exists($template, $method)) {
- $parent = $template;
- while ($parent = $parent->getParent($context)) {
- if (method_exists($parent, $method)) {
- return $parent->$method(...$args);
- }
- }
-
- throw new RuntimeError(\sprintf('Macro "%s" is not defined in template "%s".', substr($method, \strlen('macro_')), $template->getTemplateName()), $lineno, $source);
- }
-
- return $template->$method(...$args);
+ return $template->callMacro(substr($method, \strlen('macro_')), $args, $context, $lineno, $source);
}
/**
diff --git a/src/Node/Expression/MacroReferenceExpression.php b/src/Node/Expression/MacroReferenceExpression.php
index fc7a7afcc01..ec25c876293 100644
--- a/src/Node/Expression/MacroReferenceExpression.php
+++ b/src/Node/Expression/MacroReferenceExpression.php
@@ -26,9 +26,8 @@ class MacroReferenceExpression extends AbstractExpression implements SupportDefi
use SupportDefinedTestTrait;
/**
- * @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)
+ * @param string|AbstractExpression $name The bare macro name (a static identifier) or, for a dynamic
+ * call, an expression resolving to the macro name
*/
public function __construct(TemplateVariable $template, string|AbstractExpression $name, AbstractExpression $arguments, int $lineno)
{
@@ -36,13 +35,6 @@ public function __construct(TemplateVariable $template, string|AbstractExpressio
$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;
@@ -63,74 +55,41 @@ public function __clone()
public function compile(Compiler $compiler): void
{
- if ($this->hasNode('name')) {
- $this->compileDynamic($compiler);
-
- return;
- }
+ $compiler->subcompile($this->getNode('template'));
if ($this->definedTest) {
- $compiler
- ->subcompile($this->getNode('template'))
- ->raw('->hasMacro(')
- ->repr($this->getAttribute('name'))
- ->raw(', $context')
- ->raw(')')
- ;
+ $compiler->raw('->hasMacro(');
+ $this->compileName($compiler);
+ $compiler->raw(', $context)');
return;
}
+ $compiler->raw('->callMacro(');
+ $this->compileName($compiler);
$compiler
- ->subcompile($this->getNode('template'))
- ->raw('->getTemplateForMacro(')
- ->repr($this->getAttribute('name'))
+ ->raw(', ')
+ ->subcompile($this->getNode('arguments'))
->raw(', $context, ')
->repr($this->getTemplateLine())
->raw(', $this->getSourceContext())')
- ->raw(\sprintf('->%s', $this->getAttribute('name')))
- ->raw('(...')
- ->subcompile($this->getNode('arguments'))
- ->raw(')')
;
}
public function getStringCoercedChildNames(): array
{
- // Dynamic macro names are prefixed via PHP string concatenation at runtime.
+ // Dynamic macro names are string-coerced at runtime.
return $this->hasNode('name') ? ['name'] : [];
}
- private function compileDynamic(Compiler $compiler): void
+ private function compileName(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;
+ // A dynamic macro name is resolved at runtime from a context value and
+ // string-coerced before the registry lookup.
+ if ($this->hasNode('name')) {
+ $compiler->raw('(string) ')->subcompile($this->getNode('name'));
+ } else {
+ $compiler->repr($this->getAttribute('name'));
}
-
- $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(')')
- ;
}
}
diff --git a/src/Node/Expression/MethodCallExpression.php b/src/Node/Expression/MethodCallExpression.php
index 4b180534df9..b275ac26376 100644
--- a/src/Node/Expression/MethodCallExpression.php
+++ b/src/Node/Expression/MethodCallExpression.php
@@ -34,11 +34,11 @@ public function compile(Compiler $compiler): void
{
if ($this->definedTest) {
$compiler
- ->raw('method_exists($macros[')
+ ->raw('$macros[')
->repr($this->getNode('node')->getAttribute('name'))
- ->raw('], ')
- ->repr($this->getAttribute('method'))
- ->raw(')')
+ ->raw(']->hasMacro(')
+ ->repr(substr($this->getAttribute('method'), \strlen('macro_')))
+ ->raw(', $context)')
;
return;
diff --git a/src/Node/Expression/TempNameExpression.php b/src/Node/Expression/TempNameExpression.php
index f6e5f55133f..d691ba51dc5 100644
--- a/src/Node/Expression/TempNameExpression.php
+++ b/src/Node/Expression/TempNameExpression.php
@@ -16,6 +16,9 @@
class TempNameExpression extends AbstractExpression
{
+ // 4.0: re-evaluate "varargs" here once the implicit macro varargs bucket is removed
+ // (see MacroNode::VARARGS_NAME); the other names map to compiled variables ($context,
+ // $macros, $blocks, $this) and must stay.
public const RESERVED_NAMES = ['varargs', 'context', 'macros', 'blocks', 'this'];
// Prefix applied to reserved names so their compiled PHP variables cannot clash
diff --git a/src/Node/MacroNode.php b/src/Node/MacroNode.php
index 614a0e535d1..e1e4a8579e6 100644
--- a/src/Node/MacroNode.php
+++ b/src/Node/MacroNode.php
@@ -17,22 +17,32 @@
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\TempNameExpression;
use Twig\Node\Expression\Variable\LocalVariable;
+use Twig\TwigMacro;
/**
* Represents a macro node.
*
+ * This class is considered final as of Twig 3.29 and will be final in Twig
+ * 4.0.
+ *
+ * @final since Twig 3.29
+ *
* @author Fabien Potencier
*/
#[YieldReady]
class MacroNode extends Node
{
+ // 4.0: "varargs" is only the implicit, deprecated extra-arguments bucket. Once the
+ // implicit bucket is removed, this constant and the reserved-name guard in the
+ // constructor go away (a regular argument may then be named "varargs"); only an
+ // explicit "...name" variadic remains.
public const VARARGS_NAME = 'varargs';
/**
* @param BodyNode $body
* @param ArrayExpression $arguments
*/
- public function __construct(string $name, Node $body, Node $arguments, int $lineno)
+ public function __construct(string $name, Node $body, Node $arguments, int $lineno, ?string $variadicName = null)
{
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 ("%s" given).', BodyNode::class, static::class, $body::class));
@@ -54,53 +64,80 @@ public function __construct(string $name, Node $body, Node $arguments, int $line
if (TempNameExpression::RESERVED_NAME_PREFIX.self::VARARGS_NAME === $argName) {
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), $pair['value']->getTemplateLine(), $pair['value']->getSourceContext());
}
+ if (null !== $variadicName && $variadicName === $this->stripReservedPrefix($argName)) {
+ throw new SyntaxError(\sprintf('The variadic argument "%s" in macro "%s" cannot have the same name as another argument.', $variadicName, $name), $pair['value']->getTemplateLine(), $pair['value']->getSourceContext());
+ }
if (isset($seen[$argName])) {
throw new SyntaxError(\sprintf('Argument "%s" is defined twice for macro "%s".', $this->stripReservedPrefix($argName), $name), $pair['value']->getTemplateLine(), $pair['value']->getSourceContext());
}
$seen[$argName] = true;
}
- parent::__construct(['body' => $body, 'arguments' => $arguments], ['name' => $name], $lineno);
+ parent::__construct(['body' => $body, 'arguments' => $arguments], ['name' => $name, 'variadic_name' => $variadicName], $lineno);
}
public function compile(Compiler $compiler): void
{
- $compiler
- ->addDebugInfo($this)
- ->write(\sprintf('public function macro_%s(', $this->getAttribute('name')))
- ;
+ // Macros have no standalone compiled method: they are emitted as closures
+ // stored in the macro registry by compileMacroFactory(), which ModuleNode
+ // calls from the generated loadDeclaredMacros() method.
+ }
+
+ /**
+ * Compiles a "new TwigMacro(...)" expression describing this macro: its name, its
+ * compiled body (a closure bound to the template) and its declared signature.
+ */
+ public function compileMacroFactory(Compiler $compiler): void
+ {
+ // 4.0 cleanup: only an explicitly declared variadic ("...name") gets a trailing
+ // "...$bucket" parameter and a context entry; a non-variadic macro must NOT emit
+ // "...$varargs" anymore (so extra arguments raise an error), and the implicit
+ // "varargs" context entry and VARARGS_NAME handling below go away.
+ $variadicName = $this->getAttribute('variadic_name');
+ if (null === $variadicName) {
+ // Legacy implicit "varargs" bucket: to be removed in 4.0.
+ $bucketName = self::VARARGS_NAME;
+ $bucketVar = self::VARARGS_NAME;
+ } else {
+ $bucketName = $variadicName;
+ $bucketVar = \in_array($variadicName, TempNameExpression::RESERVED_NAMES, true) ? TempNameExpression::RESERVED_NAME_PREFIX.$variadicName : $variadicName;
+ }
/** @var ArrayExpression $arguments */
$arguments = $this->getNode('arguments');
+
+ $compiler
+ ->raw('new \\'.TwigMacro::class.'(')
+ ->string($this->getAttribute('name'))
+ ->raw(', function (')
+ ;
+
foreach ($arguments->getKeyValuePairs() as $pair) {
- $name = $pair['key'];
- $default = $pair['value'];
$compiler
- ->subcompile($name)
+ ->subcompile($pair['key'])
->raw(' = ')
- ->subcompile($default)
+ ->subcompile($pair['value'])
->raw(', ')
;
}
$compiler
- ->raw('...$varargs')
- ->raw("): string|Markup\n")
- ->write("{\n")
+ ->raw('...$'.$bucketVar)
+ ->raw("): string|Markup {\n")
->indent()
+ ->addDebugInfo($this)
->write("\$macros = \$this->macros;\n")
->write("\$context = [\n")
->indent()
;
foreach ($arguments->getKeyValuePairs() as $pair) {
- $name = $pair['key'];
- $var = $this->stripReservedPrefix($name->getAttribute('name'));
+ $var = $this->stripReservedPrefix($pair['key']->getAttribute('name'));
$compiler
->write('')
->string($var)
->raw(' => ')
- ->subcompile($name)
+ ->subcompile($pair['key'])
->raw(",\n")
;
}
@@ -109,9 +146,9 @@ public function compile(Compiler $compiler): void
$compiler
->write('')
- ->string(self::VARARGS_NAME)
+ ->string($bucketName)
->raw(' => ')
- ->raw("\$varargs,\n")
+ ->raw('$'.$bucketVar.",\n")
->outdent()
->write("] + \$this->env->getGlobals();\n\n")
->write("\$blocks = [];\n\n")
@@ -119,7 +156,18 @@ public function compile(Compiler $compiler): void
->subcompile($node)
->raw("\n")
->outdent()
- ->write("}\n\n")
+ ->write('}, ')
+ ;
+
+ $signature = [];
+ foreach ($arguments->getKeyValuePairs() as $pair) {
+ $default = $pair['value'];
+ $signature[$this->stripReservedPrefix($pair['key']->getAttribute('name'))] = !($default->hasAttribute('is_implicit') && $default->getAttribute('is_implicit'));
+ }
+
+ $compiler
+ ->repr($signature)
+ ->raw(', '.(null !== $variadicName ? 'true' : 'false').')')
;
}
diff --git a/src/Node/MacrosNode.php b/src/Node/MacrosNode.php
new file mode 100644
index 00000000000..1bc3030ca53
--- /dev/null
+++ b/src/Node/MacrosNode.php
@@ -0,0 +1,81 @@
+
+ */
+#[YieldReady]
+final class MacrosNode extends Node
+{
+ /**
+ * @param array $macros
+ */
+ public function __construct(array $macros = [])
+ {
+ foreach ($macros as $name => $macro) {
+ if (!$macro instanceof MacroNode) {
+ throw new \InvalidArgumentException(\sprintf('Using "%s" for the macro "%s" of "%s" is not supported. You must pass a "%s" instance.', get_debug_type($macro), $name, static::class, MacroNode::class));
+ }
+ }
+
+ parent::__construct($macros);
+ }
+
+ public function setNode(string $name, Node $node): void
+ {
+ if (!$node instanceof MacroNode) {
+ throw new \LogicException(\sprintf('A "%s" can only contain "%s" nodes; replacing the macro "%s" with a "%s" node is not supported.', static::class, MacroNode::class, $name, get_debug_type($node)));
+ }
+
+ parent::setNode($name, $node);
+ }
+
+ public function compile(Compiler $compiler): void
+ {
+ if (!\count($this)) {
+ return;
+ }
+
+ $compiler
+ ->write("protected function loadDeclaredMacros(): array\n", "{\n")
+ ->indent()
+ ->write("return [\n")
+ ->indent()
+ ;
+
+ /** @var MacroNode $macro */
+ foreach ($this as $macro) {
+ $compiler
+ ->write('')
+ ->string($macro->getAttribute('name'))
+ ->raw(' => ')
+ ;
+ $macro->compileMacroFactory($compiler);
+ $compiler->raw(",\n");
+ }
+
+ $compiler
+ ->outdent()
+ ->write("];\n")
+ ->outdent()
+ ->write("}\n\n")
+ ;
+ }
+}
diff --git a/src/Parser.php b/src/Parser.php
index 5ef1aea8dc8..fdceddf6f7c 100644
--- a/src/Parser.php
+++ b/src/Parser.php
@@ -27,6 +27,7 @@
use Twig\Node\Expression\Variable\AssignTemplateVariable;
use Twig\Node\Expression\Variable\TemplateVariable;
use Twig\Node\MacroNode;
+use Twig\Node\MacrosNode;
use Twig\Node\ModuleNode;
use Twig\Node\Node;
use Twig\Node\Nodes;
@@ -128,7 +129,7 @@ public function parse(TokenStream $stream, $test = null, bool $dropNeedle = fals
new BodyNode([$body]),
$this->parent,
$this->blocks ? new Nodes($this->blocks) : new EmptyNode(),
- $this->macros ? new Nodes($this->macros) : new EmptyNode(),
+ new MacrosNode($this->macros),
$this->traits ? new Nodes($this->traits) : new EmptyNode(),
$this->embeddedTemplates ? new Nodes($this->embeddedTemplates) : new EmptyNode(),
$stream->getSourceContext(),
diff --git a/src/Template.php b/src/Template.php
index 3d74460b0e3..69ed82815a5 100644
--- a/src/Template.php
+++ b/src/Template.php
@@ -14,6 +14,7 @@
use Twig\Error\Error;
use Twig\Error\RuntimeError;
+use Twig\Node\Expression\MacroReferenceExpression;
/**
* Default base class for compiled templates.
@@ -42,6 +43,11 @@ abstract class Template
private $useYield;
+ /**
+ * @var array|null
+ */
+ private ?array $declaredMacros = null;
+
public function __construct(
protected Environment $env,
) {
@@ -84,8 +90,8 @@ public function getParent(array $context): self|TemplateWrapper|false
// functions, method calls) when the parent name is dynamic. Make sure
// the sandbox security check runs first so those expressions cannot
// bypass the allow-list when getParent() is reached before the first
- // ensureSecurityChecked() call on this template (e.g. via
- // getTemplateForMacro() or yieldBlock() into a pre-warmed instance).
+ // ensureSecurityChecked() call on this template (e.g. via a macro call
+ // resolved against a parent, or yieldBlock() into a pre-warmed instance).
$this->ensureSecurityChecked();
if (!$parent = $this->doGetParent($context)) {
@@ -506,37 +512,124 @@ public function yieldParentBlock($name, array $context, array $blocks = []): ite
}
}
- protected function hasMacro(string $name, array $context): bool
+ /**
+ * Tells whether the macro of the given name is defined in this template or one of
+ * its parents. Used by the "is defined" test.
+ *
+ * Matching is case-insensitive on 3.x to mirror the historical behavior (macros used
+ * to compile to PHP methods, whose names fold ASCII case); it becomes case-sensitive
+ * in 4.0.
+ */
+ public function hasMacro(string $name, array $context): bool
{
- if (method_exists($this, $name)) {
+ if (null !== $declaration = $this->findDeclaredMacroName($name, $context)) {
+ [$declaredName, $templateName] = $declaration;
+ if ($declaredName !== $name) {
+ trigger_deprecation('twig/twig', '3.29', 'Testing whether the macro "%s" (defined in template "%s") is defined as "%s" is deprecated; macro names will be case-sensitive in Twig 4.0 and this test will return false.', $declaredName, $templateName, $name);
+ }
+
return true;
}
- if (!$parent = $this->getParent($context)) {
- return false;
- }
+ // To be removed in 4.0: before Twig 3.29, static macro calls compiled to "macro_"-prefixed
+ // method calls, so a legacy MacroReferenceExpression built by an extension may still carry
+ // the prefix.
+ return str_starts_with($name, 'macro_') && null !== $this->findDeclaredMacroName(substr($name, \strlen('macro_')), $context);
+ }
- return $parent->hasMacro($name, $context);
+ /**
+ * @return array{string, string}|null The name under which the macro is declared and the name of the template declaring it
+ */
+ private function findDeclaredMacroName(string $name, array $context): ?array
+ {
+ $template = $this;
+ while (true) {
+ $macros = $template->declaredMacros ??= $template->loadDeclaredMacros();
+ if (isset($macros[$name])) {
+ return [$name, $template->getTemplateName()];
+ }
+ foreach ($macros as $declaredName => $macro) {
+ if (0 === strcasecmp($declaredName, $name)) {
+ return [$declaredName, $template->getTemplateName()];
+ }
+ }
+
+ if (!$parent = $template->getParent($context)) {
+ return null;
+ }
+
+ $template = $parent->unwrap();
+ }
}
- protected function getTemplateForMacro(string $name, array $context, int $line, Source $source): self
+ private function getDeclaredMacro(string $name): ?TwigMacro
{
- if (method_exists($this, $name)) {
+ $macros = $this->declaredMacros ??= $this->loadDeclaredMacros();
+ if (isset($macros[$name])) {
$this->ensureSecurityChecked();
- return $this;
+ return $macros[$name];
}
- $parent = $this;
- while ($parent = $parent->getParent($context)) {
- if (method_exists($parent, $name)) {
- $parent->ensureSecurityChecked();
+ // To be removed in 4.0: macro resolution has historically been case-insensitive
+ // because macros compile to PHP methods; macro names are case-sensitive in 4.0.
+ foreach ($macros as $declaredName => $macro) {
+ if (0 === strcasecmp($declaredName, $name)) {
+ trigger_deprecation('twig/twig', '3.29', 'Calling the macro "%s" (defined in template "%s") as "%s" is deprecated; macro names will be case-sensitive in Twig 4.0.', $declaredName, $this->getTemplateName(), $name);
- return $parent;
+ $this->ensureSecurityChecked();
+
+ return $macro;
}
}
- throw new RuntimeError(\sprintf('Macro "%s" is not defined in template "%s".', substr($name, \strlen('macro_')), $this->getTemplateName()), $line, $source);
+ return null;
+ }
+
+ /**
+ * @return array
+ */
+ protected function loadDeclaredMacros(): array
+ {
+ return [];
+ }
+
+ /**
+ * Resolves and calls a macro by name.
+ *
+ * @param array $arguments
+ */
+ public function callMacro(string $name, array $arguments, array $context, int $line, Source $source): string|Markup
+ {
+ if (null === $macro = $this->resolveMacro($name, $context)) {
+ // To be removed in 4.0: before Twig 3.29, static macro calls compiled to "macro_"-prefixed
+ // method calls, so a legacy MacroReferenceExpression built by an extension may still carry
+ // the prefix.
+ if (!str_starts_with($name, 'macro_') || null === $macro = $this->resolveMacro($bareName = substr($name, \strlen('macro_')), $context)) {
+ throw new RuntimeError(\sprintf('Macro "%s" is not defined in template "%s".', $name, $this->getTemplateName()), $line, $source);
+ }
+
+ trigger_deprecation('twig/twig', '3.29', 'Calling the macro "%s" via the "macro_"-prefixed name "%s" is deprecated; pass the bare macro name to "%s" instead.', $bareName, $name, MacroReferenceExpression::class);
+ }
+
+ // In 4.0, callLegacy() becomes the strict call(): the deprecated call shapes turn into hard errors.
+ return $macro->callLegacy($arguments, $source, $line);
+ }
+
+ private function resolveMacro(string $name, array $context): ?TwigMacro
+ {
+ $template = $this;
+ while (true) {
+ if (null !== $macro = $template->getDeclaredMacro($name)) {
+ return $macro;
+ }
+
+ if (!$parent = $template->getParent($context)) {
+ return null;
+ }
+
+ $template = $parent->unwrap();
+ }
}
/**
diff --git a/src/TokenParser/FromTokenParser.php b/src/TokenParser/FromTokenParser.php
index 1c80a171777..7a86353723f 100644
--- a/src/TokenParser/FromTokenParser.php
+++ b/src/TokenParser/FromTokenParser.php
@@ -55,8 +55,13 @@ public function parse(Token $token): Node
$internalRef = new AssignTemplateVariable(new TemplateVariable(null, $token->getLine()), $this->parser->isMainScope());
$node = new ImportNode($macro, $internalRef, $token->getLine());
+ // Each alias is registered as a "function" imported symbol mapping the local name
+ // to the macro name and the internal template variable: a bare call like "my_macro()"
+ // is syntactically a function call, so FunctionExpressionParser resolves it through
+ // this symbol into a MacroReferenceExpression. From there, "from" and "import" calls
+ // share the same runtime path (Template::callMacro()).
foreach ($targets as $name => $alias) {
- $this->parser->addImportedSymbol('function', $alias->getAttribute('name'), 'macro_'.$name, $internalRef);
+ $this->parser->addImportedSymbol('function', $alias->getAttribute('name'), $name, $internalRef);
}
return $node;
diff --git a/src/TokenParser/MacroTokenParser.php b/src/TokenParser/MacroTokenParser.php
index 7531fe02fe0..8f1fb83b3b9 100644
--- a/src/TokenParser/MacroTokenParser.php
+++ b/src/TokenParser/MacroTokenParser.php
@@ -16,6 +16,7 @@
use Twig\Node\ConfigNode;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\ConstantExpression;
+use Twig\Node\Expression\TempNameExpression;
use Twig\Node\Expression\Unary\NegUnary;
use Twig\Node\Expression\Unary\PosUnary;
use Twig\Node\Expression\Variable\LocalVariable;
@@ -39,7 +40,7 @@ public function parse(Token $token): Node
$lineno = $token->getLine();
$stream = $this->parser->getStream();
$name = $stream->expect(Token::NAME_TYPE)->getValue();
- $arguments = $this->parseDefinition();
+ [$arguments, $variadicName] = $this->parseDefinition($name);
$stream->expect(Token::BLOCK_END_TYPE);
$this->parser->pushLocalScope();
@@ -54,7 +55,7 @@ public function parse(Token $token): Node
$this->parser->popLocalScope();
$stream->expect(Token::BLOCK_END_TYPE);
- $this->parser->setMacro($name, new MacroNode($name, new BodyNode([$body]), $arguments, $lineno));
+ $this->parser->setMacro($name, new MacroNode($name, new BodyNode([$body]), $arguments, $lineno, $variadicName));
return new ConfigNode($lineno);
}
@@ -69,9 +70,13 @@ public function getTag(): string
return 'macro';
}
- private function parseDefinition(): ArrayExpression
+ /**
+ * @return array{ArrayExpression, string|null}
+ */
+ private function parseDefinition(string $macroName): array
{
$arguments = new ArrayExpression([], $this->parser->getCurrentToken()->getLine());
+ $variadicName = null;
$stream = $this->parser->getStream();
$stream->expect(Token::OPERATOR_TYPE, '(', 'A list of arguments must begin with an opening parenthesis');
while (!$stream->test(Token::PUNCTUATION_TYPE, ')')) {
@@ -84,6 +89,22 @@ private function parseDefinition(): ArrayExpression
}
}
+ if ($stream->nextIf(Token::OPERATOR_TYPE, '...')) {
+ $token = $stream->expect(Token::NAME_TYPE, null, 'A variadic argument must be a name');
+ $variadicName = (new LocalVariable($token->getValue(), $token->getLine()))->getAttribute('name');
+ if (str_starts_with($variadicName, TempNameExpression::RESERVED_NAME_PREFIX)) {
+ $variadicName = substr($variadicName, \strlen(TempNameExpression::RESERVED_NAME_PREFIX));
+ }
+ if ($stream->test(Token::OPERATOR_TYPE, '=')) {
+ throw new SyntaxError(\sprintf('The variadic argument "%s" in macro "%s" cannot have a default value.', $variadicName, $macroName), $token->getLine(), $stream->getSourceContext());
+ }
+ if ($stream->nextIf(Token::PUNCTUATION_TYPE, ',') && !$stream->test(Token::PUNCTUATION_TYPE, ')')) {
+ throw new SyntaxError(\sprintf('The variadic argument "%s" in macro "%s" must be the last one.', $variadicName, $macroName), $token->getLine(), $stream->getSourceContext());
+ }
+
+ break;
+ }
+
$token = $stream->expect(Token::NAME_TYPE, null, 'An argument must be a name');
$name = new LocalVariable($token->getValue(), $this->parser->getCurrentToken()->getLine());
if ($token = $stream->nextIf(Token::OPERATOR_TYPE, '=')) {
@@ -100,7 +121,7 @@ private function parseDefinition(): ArrayExpression
}
$stream->expect(Token::PUNCTUATION_TYPE, ')', 'A list of arguments must be closed by a parenthesis');
- return $arguments;
+ return [$arguments, $variadicName];
}
// checks that the node only contains "constant" elements
diff --git a/src/TwigMacro.php b/src/TwigMacro.php
new file mode 100644
index 00000000000..70e11e7c174
--- /dev/null
+++ b/src/TwigMacro.php
@@ -0,0 +1,186 @@
+
+ *
+ * @internal This class is an implementation detail of the per-template macro
+ * registry: compiled templates instantiate it, but it is not part of
+ * the public API, neither on 3.x nor in 4.0.
+ */
+final class TwigMacro
+{
+ /**
+ * @var array
+ */
+ private array $argumentIndexes = [];
+
+ /**
+ * @var array
+ */
+ private array $requiredNames = [];
+
+ /**
+ * Twig argument names that do not match their compiled closure parameter name
+ * (reserved names get prefixed by the compiler). Only needed by callLegacy()'s
+ * native binding; to be removed in 4.0 together with it.
+ *
+ * @var array
+ */
+ private array $renamedArguments = [];
+
+ private int $requiredCount = 0;
+
+ /**
+ * @param \Closure(mixed...): (string|Markup) $body The compiled macro body, invoked with the bound arguments in definition order
+ * @param array $arguments The declared argument names mapped to whether they have a default value, in definition order
+ */
+ public function __construct(
+ private string $name,
+ private \Closure $body,
+ private array $arguments = [],
+ private bool $variadic = false,
+ ) {
+ $i = 0;
+ foreach ($arguments as $argName => $hasDefault) {
+ $this->argumentIndexes[$argName] = $i;
+ if (\in_array($argName, TempNameExpression::RESERVED_NAMES, true)) {
+ $this->renamedArguments[$argName] = TempNameExpression::RESERVED_NAME_PREFIX.$argName;
+ }
+ if (!$hasDefault) {
+ $this->requiredCount = $i + 1;
+ $this->requiredNames[$argName] = true;
+ }
+ ++$i;
+ }
+ }
+
+ /**
+ * Invokes the macro the way Twig 3.x always has (lenient argument handling),
+ * while reporting the cases that will become errors in Twig 4.0.
+ *
+ * In 4.0, this becomes the strict call() method: the scan below already detects
+ * every deprecated case, so triggerLegacyDeprecations() turns into upfront
+ * throws and the rest stays as is.
+ *
+ * @param array $arguments Positional arguments keyed by their integer position
+ * and named arguments keyed by their name
+ * @param Source $source The source of the template making the call, used to enrich errors
+ * @param int $lineno The line of the call in that template, used to enrich errors
+ */
+ public function callLegacy(array $arguments, Source $source, int $lineno): string|Markup
+ {
+ // Spreading the arguments as-is is equivalent to resolving them: PHP binds
+ // them natively, maps the named ones onto their parameter, fills the
+ // defaults, and collects the extra ones into the variadic bucket. Only the
+ // cases where PHP would report its own error (with closure-centric wording)
+ // are detected upfront and turned into Twig errors.
+ if (array_is_list($arguments)) {
+ $count = \count($arguments);
+ if ($count < $this->requiredCount || (!$this->variadic && $count > \count($this->arguments))) {
+ $this->triggerLegacyDeprecations($arguments, $count, $source, $lineno);
+ }
+
+ return ($this->body)(...$arguments);
+ }
+
+ $positionalCount = 0;
+ $namedRequired = 0;
+ $sawNamed = false;
+ $misordered = false;
+ $duplicate = null;
+ $hasUnknownNamed = false;
+ foreach ($arguments as $key => $value) {
+ if (\is_int($key)) {
+ $misordered = $misordered || $sawNamed;
+ ++$positionalCount;
+ } else {
+ $sawNamed = true;
+ if (null === $i = $this->argumentIndexes[$key] ?? null) {
+ $hasUnknownNamed = true;
+ } else {
+ if (null === $duplicate && $i < $positionalCount) {
+ $duplicate = $key;
+ }
+ if (isset($this->requiredNames[$key])) {
+ ++$namedRequired;
+ }
+ }
+ }
+ }
+
+ if ($misordered) {
+ throw new RuntimeError(\sprintf('Positional arguments cannot be used after named arguments for macro "%s".', $this->name), $lineno, $source);
+ }
+ if (null !== $duplicate) {
+ throw new RuntimeError(\sprintf('Argument "%s" is defined twice for macro "%s".', $duplicate, $this->name), $lineno, $source);
+ }
+
+ // For a fully named call, the coverage of the required arguments is exact; a
+ // mixed call falls back to the precise (and slower) per-argument check.
+ $mayMissRequired = 0 === $positionalCount
+ ? $namedRequired < \count($this->requiredNames)
+ : $positionalCount < $this->requiredCount;
+
+ if ($mayMissRequired || (!$this->variadic && ($hasUnknownNamed || $positionalCount > \count($this->arguments)))) {
+ $this->triggerLegacyDeprecations($arguments, $positionalCount, $source, $lineno);
+ }
+
+ foreach ($this->renamedArguments as $name => $parameterName) {
+ if (\array_key_exists($name, $arguments)) {
+ $arguments[$parameterName] = $arguments[$name];
+ unset($arguments[$name]);
+ }
+ }
+
+ return ($this->body)(...$arguments);
+ }
+
+ /**
+ * To be removed in 4.0 (these deprecated cases become hard errors).
+ *
+ * @param array $arguments The full argument array; string keys are the named arguments
+ */
+ private function triggerLegacyDeprecations(array $arguments, int $positionalCount, Source $source, int $lineno): void
+ {
+ if ($positionalCount < $this->requiredCount) {
+ foreach ($this->requiredNames as $argName => $required) {
+ if ($this->argumentIndexes[$argName] < $positionalCount || \array_key_exists($argName, $arguments)) {
+ continue;
+ }
+
+ trigger_deprecation('twig/twig', '3.29', 'Not passing a value for the "%s" argument of macro "%s" is deprecated and the argument will be required in Twig 4.0; give it a default value in the macro definition or pass a value when calling it (in "%s" at line %d).', $argName, $this->name, $source->getName(), $lineno);
+ }
+ }
+
+ if ($this->variadic) {
+ return;
+ }
+
+ if ($positionalCount > \count($this->arguments)) {
+ trigger_deprecation('twig/twig', '3.29', 'Passing more arguments than the macro "%s" accepts is deprecated and will throw in Twig 4.0; declare a variadic argument ("...name") in the macro definition to accept extra arguments (in "%s" at line %d).', $this->name, $source->getName(), $lineno);
+ }
+
+ foreach ($arguments as $name => $value) {
+ if (\is_string($name) && !isset($this->argumentIndexes[$name])) {
+ trigger_deprecation('twig/twig', '3.29', 'Passing the unknown named argument "%s" to the macro "%s" is deprecated and will throw in Twig 4.0; declare a variadic argument ("...name") in the macro definition to accept it (in "%s" at line %d).', $name, $this->name, $source->getName(), $lineno);
+ }
+ }
+ }
+}
diff --git a/tests/CallMacroTest.php b/tests/CallMacroTest.php
new file mode 100644
index 00000000000..f8000caeae7
--- /dev/null
+++ b/tests/CallMacroTest.php
@@ -0,0 +1,207 @@
+load([
+ 'index' => '{% macro greet(name, greeting = "Hello") %}{{ greeting }} {{ name }}{% endmacro %}',
+ ]);
+
+ $this->assertSame('Hello World', (string) $this->callMacro($template, 'greet', ['World']));
+ $this->assertSame('Hi World', (string) $this->callMacro($template, 'greet', ['name' => 'World', 'greeting' => 'Hi']));
+ }
+
+ public function testCallMacroLooksMacrosUpInParentTemplates()
+ {
+ $template = $this->load([
+ 'index' => '{% extends "parent" %}',
+ 'parent' => '{% macro greet(name) %}Hi {{ name }}{% endmacro %}',
+ ]);
+
+ $this->assertSame('Hi World', (string) $this->callMacro($template, 'greet', ['World']));
+ }
+
+ public function testLenientMacroCallReportsADeprecationAtTheCallSite()
+ {
+ $twig = new Environment(new ArrayLoader([
+ 'index' => "{% from _self import greet %}\n{% macro greet(name) %}{% endmacro %}\n{{ greet('a', 'b') }}",
+ ]));
+
+ $deprecations = $this->collectDeprecations(static fn () => $twig->render('index'));
+
+ $this->assertSame([
+ 'Since twig/twig 3.29: Passing more arguments than the macro "greet" accepts is deprecated and will throw in Twig 4.0; declare a variadic argument ("...name") in the macro definition to accept extra arguments (in "index" at line 3).',
+ ], $deprecations);
+ }
+
+ public function testCallingAMacroWithACaseMismatchedNameTriggersADeprecation()
+ {
+ $twig = new Environment(new ArrayLoader([
+ 'index' => "{% import _self as m %}\n{% macro greet(name = '') %}Hi {{ name }}{% endmacro %}\n{{ m.GREET('World') }}",
+ ]));
+
+ $output = null;
+ $deprecations = $this->collectDeprecations(static function () use ($twig, &$output) {
+ $output = $twig->render('index');
+ });
+
+ $this->assertSame('Hi World', trim($output));
+ $this->assertSame([
+ 'Since twig/twig 3.29: Calling the macro "greet" (defined in template "index") as "GREET" is deprecated; macro names will be case-sensitive in Twig 4.0.',
+ ], $deprecations);
+ }
+
+ public function testCallMacroLooksCaseMismatchedMacrosUpInParentTemplates()
+ {
+ $template = $this->load([
+ 'index' => '{% extends "parent" %}',
+ 'parent' => '{% macro greet(name = "") %}Hi {{ name }}{% endmacro %}',
+ ]);
+
+ $output = null;
+ $deprecations = $this->collectDeprecations(function () use ($template, &$output) {
+ $output = (string) $this->callMacro($template, 'Greet', ['World']);
+ });
+
+ $this->assertSame('Hi World', $output);
+ $this->assertSame([
+ 'Since twig/twig 3.29: Calling the macro "greet" (defined in template "parent") as "Greet" is deprecated; macro names will be case-sensitive in Twig 4.0.',
+ ], $deprecations);
+ }
+
+ public function testHasMacroDeprecatesACaseMismatchedMatchOnly()
+ {
+ $template = $this->load(['index' => '{% macro greet(name) %}Hi {{ name }}{% endmacro %}']);
+
+ $deprecations = $this->collectDeprecations(function () use ($template) {
+ $this->assertTrue($template->hasMacro('greet', []));
+ $this->assertTrue($template->hasMacro('GREET', []));
+ $this->assertFalse($template->hasMacro('missing', []));
+ });
+
+ $this->assertSame([
+ 'Since twig/twig 3.29: Testing whether the macro "greet" (defined in template "index") is defined as "GREET" is deprecated; macro names will be case-sensitive in Twig 4.0 and this test will return false.',
+ ], $deprecations, 'The "is defined" test must deprecate only a case-mismatched macro name.');
+ }
+
+ public function testCallMacroResolvesALegacyPrefixedNameWithADeprecation()
+ {
+ $template = $this->load(['index' => '{% macro greet(name = "") %}Hi {{ name }}{% endmacro %}']);
+
+ $output = null;
+ $deprecations = $this->collectDeprecations(function () use ($template, &$output) {
+ $output = (string) $this->callMacro($template, 'macro_greet', ['World']);
+ });
+
+ $this->assertSame('Hi World', $output);
+ $this->assertSame([
+ 'Since twig/twig 3.29: Calling the macro "greet" via the "macro_"-prefixed name "macro_greet" is deprecated; pass the bare macro name to "Twig\Node\Expression\MacroReferenceExpression" instead.',
+ ], $deprecations);
+ }
+
+ public function testCallMacroPrefersAMacroActuallyNamedWithThePrefix()
+ {
+ $template = $this->load(['index' => '{% macro macro_greet(name = "") %}Prefixed {{ name }}{% endmacro %}{% macro greet(name = "") %}Bare {{ name }}{% endmacro %}']);
+
+ $output = null;
+ $deprecations = $this->collectDeprecations(function () use ($template, &$output) {
+ $output = (string) $this->callMacro($template, 'macro_greet', ['World']);
+ });
+
+ $this->assertSame('Prefixed World', $output);
+ $this->assertSame([], $deprecations);
+ }
+
+ public function testCallMacroThrowsWithThePrefixedNameWhenNeitherNameExists()
+ {
+ $template = $this->load(['index' => 'no macro here']);
+
+ $this->expectException(RuntimeError::class);
+ $this->expectExceptionMessage('Macro "macro_missing" is not defined in template "index"');
+
+ $this->callMacro($template, 'macro_missing', []);
+ }
+
+ public function testHasMacroResolvesALegacyPrefixedNameSilently()
+ {
+ $template = $this->load(['index' => '{% macro greet(name) %}Hi {{ name }}{% endmacro %}']);
+
+ $deprecations = $this->collectDeprecations(function () use ($template) {
+ $this->assertTrue($template->hasMacro('macro_greet', []));
+ $this->assertFalse($template->hasMacro('macro_missing', []));
+ });
+
+ $this->assertSame([], $deprecations);
+ }
+
+ public function testCallMacroThrowsForAnUnknownMacro()
+ {
+ $template = $this->load(['index' => 'no macro here']);
+
+ $this->expectException(RuntimeError::class);
+ $this->expectExceptionMessage('Macro "missing" is not defined in template "index"');
+
+ $this->callMacro($template, 'missing', []);
+ }
+
+ public function testDeprecatedCoreExtensionCallMacroDelegatesToTheRegistry()
+ {
+ $template = $this->load(['index' => '{% macro greet(name) %}Hi {{ name }}{% endmacro %}']);
+
+ $this->assertSame('Hi Bob', (string) CoreExtension::callMacro($template, 'macro_greet', ['Bob'], 1, [], new Source('', 'index')));
+ }
+
+ private function callMacro(Template $template, string $name, array $arguments): mixed
+ {
+ return $template->callMacro($name, $arguments, [], 1, new Source('', 'index'));
+ }
+
+ private function collectDeprecations(callable $fn): array
+ {
+ $deprecations = [];
+ set_error_handler(static function ($type, $message) use (&$deprecations) {
+ if (\E_USER_DEPRECATED === $type) {
+ $deprecations[] = $message;
+
+ return true;
+ }
+
+ return false;
+ });
+
+ try {
+ $fn();
+ } finally {
+ restore_error_handler();
+ }
+
+ return $deprecations;
+ }
+
+ private function load(array $templates): Template
+ {
+ $twig = new Environment(new ArrayLoader($templates));
+
+ return $twig->load('index')->unwrap();
+ }
+}
diff --git a/tests/Extension/SandboxStateChangeTest.php b/tests/Extension/SandboxStateChangeTest.php
index f6b1fcc6e23..9172718a5d9 100644
--- a/tests/Extension/SandboxStateChangeTest.php
+++ b/tests/Extension/SandboxStateChangeTest.php
@@ -268,9 +268,9 @@ public function testFunctionBypassThroughPreWarmedParent()
public function testDynamicParentFilterRejectedWhenReachedViaMacroImport()
{
- // Regression: getTemplateForMacro() walks getParent() to find the
- // macro on a parent template. When the imported template has a
- // dynamic {% extends %}, doGetParent() evaluates the user expression.
+ // Regression: a macro call walks getParent() to find the macro on a
+ // parent template. When the imported template has a dynamic
+ // {% extends %}, doGetParent() evaluates the user expression.
// The sandbox security check must run on the imported template
// *before* doGetParent() executes, otherwise a forbidden filter on
// the parent name escapes the allow-list.
diff --git a/tests/Fixtures/macros/constant_name_non_identifier.test b/tests/Fixtures/macros/constant_name_non_identifier.test
new file mode 100644
index 00000000000..cde672b5bc3
--- /dev/null
+++ b/tests/Fixtures/macros/constant_name_non_identifier.test
@@ -0,0 +1,10 @@
+--TEST--
+macro called with a constant name that is not a valid identifier
+--TEMPLATE--
+{% import _self as macros %}
+
+{{ macros.('foo-bar')() }}
+--DATA--
+return []
+--EXCEPTION--
+Twig\Error\RuntimeError: Macro "foo-bar" is not defined in template "index.twig" in "index.twig" at line 4.
diff --git a/tests/Fixtures/macros/dynamic_name_non_string.test b/tests/Fixtures/macros/dynamic_name_non_string.test
new file mode 100644
index 00000000000..3cdc002999d
--- /dev/null
+++ b/tests/Fixtures/macros/dynamic_name_non_string.test
@@ -0,0 +1,9 @@
+--TEST--
+macro called with a dynamic name coerces the name before lookup
+--TEMPLATE--
+{% import _self as macros %}
+{{ macros.(name)() }}
+--DATA--
+return ['name' => null]
+--EXCEPTION--
+Twig\Error\RuntimeError: Macro "" is not defined in template "index.twig" in "index.twig" at line 3.
diff --git a/tests/Fixtures/macros/implicit_optional_arguments.legacy.test b/tests/Fixtures/macros/implicit_optional_arguments.legacy.test
new file mode 100644
index 00000000000..26baf69481c
--- /dev/null
+++ b/tests/Fixtures/macros/implicit_optional_arguments.legacy.test
@@ -0,0 +1,22 @@
+--TEST--
+macro arguments without a default value are implicitly optional (deprecated)
+--TEMPLATE--
+{% import _self as test %}
+{% from _self import test %}
+
+{% macro test(a, b) -%}
+ {{ a|default('a') }}
+ {{- b|default('b') }}
+{%- endmacro %}
+
+{{ test.test() }}
+{{ test() }}
+{{ test.test(1, "c") }}
+{{ test(1, "c") }}
+--DATA--
+return []
+--EXPECT--
+a b
+a b
+1 c
+1 c
diff --git a/tests/Fixtures/macros/reserved_variables.test b/tests/Fixtures/macros/reserved_variables.test
index 05dd9213008..1587f1d2ede 100644
--- a/tests/Fixtures/macros/reserved_variables.test
+++ b/tests/Fixtures/macros/reserved_variables.test
@@ -2,13 +2,18 @@
macro
--TEMPLATE--
{% from _self import test %}
+{% import _self as macros %}
-{% macro test(this) -%}
- {{ this }}
+{% macro test(blocks) -%}
+ {{ blocks }}
{%- endmacro %}
-{{ test(this) }}
+{{ test(blocks) }}
+{{ test(blocks: blocks) }}
+{{ macros.test(blocks: blocks) }}
--DATA--
-return ['this' => 'foo']
+return ['blocks' => 'foo']
--EXPECT--
foo
+foo
+foo
diff --git a/tests/Fixtures/macros/simple.test b/tests/Fixtures/macros/simple.test
index 8fc6b477fb2..f6bd688f288 100644
--- a/tests/Fixtures/macros/simple.test
+++ b/tests/Fixtures/macros/simple.test
@@ -4,7 +4,7 @@ macro
{% import _self as test %}
{% from _self import test %}
-{% macro test(a, b) -%}
+{% macro test(a = null, b = null) -%}
{{ a|default('a') }}
{{- b|default('b') }}
{%- endmacro %}
diff --git a/tests/Fixtures/macros/varargs.test b/tests/Fixtures/macros/varargs.test
index dd4b5c9f471..73104dfd6c3 100644
--- a/tests/Fixtures/macros/varargs.test
+++ b/tests/Fixtures/macros/varargs.test
@@ -3,11 +3,11 @@ macro with arbitrary arguments
--TEMPLATE--
{% from _self import test1, test2 %}
-{% macro test1(var) %}
+{% macro test1(var, ...varargs) %}
{{- var }}: {{ varargs|join(", ") }}
{% endmacro %}
-{% macro test2() %}
+{% macro test2(...varargs) %}
{{- varargs|join(", ") }}
{% endmacro %}
diff --git a/tests/Fixtures/macros/varargs_implicit.legacy.test b/tests/Fixtures/macros/varargs_implicit.legacy.test
new file mode 100644
index 00000000000..ae78edd2a54
--- /dev/null
+++ b/tests/Fixtures/macros/varargs_implicit.legacy.test
@@ -0,0 +1,21 @@
+--TEST--
+macro with arbitrary arguments collected in the implicit "varargs" variable (deprecated)
+--TEMPLATE--
+{% from _self import test1, test2 %}
+
+{% macro test1(var) %}
+ {{- var }}: {{ varargs|join(", ") }}
+{% endmacro %}
+
+{% macro test2() %}
+ {{- varargs|join(", ") }}
+{% endmacro %}
+
+{{ test1("foo", "bar", "foobar") }}
+{{ test2("foo", "bar", "foobar") }}
+--DATA--
+return []
+--EXPECT--
+foo: bar, foobar
+
+foo, bar, foobar
diff --git a/tests/Fixtures/macros/variadic.test b/tests/Fixtures/macros/variadic.test
new file mode 100644
index 00000000000..060d4adbb89
--- /dev/null
+++ b/tests/Fixtures/macros/variadic.test
@@ -0,0 +1,22 @@
+--TEST--
+macro with an explicit variadic argument
+--TEMPLATE--
+{% from _self import tag, list %}
+
+{% macro tag(name, ...attributes) -%}
+ <{{ name }}{% for attr in attributes %} {{ attr }}{% endfor %}>
+{%- endmacro %}
+
+{% macro list(...items) -%}
+ {{ items|join(", ") }}
+{%- endmacro %}
+
+{{ tag("div", "a", "b") }}
+{{ tag("br") }}
+{{ list(1, 2, 3) }}
+--DATA--
+return []
+--EXPECT--
+
+
+1, 2, 3
diff --git a/tests/Fixtures/macros/variadic_default_value.test b/tests/Fixtures/macros/variadic_default_value.test
new file mode 100644
index 00000000000..12fcff20222
--- /dev/null
+++ b/tests/Fixtures/macros/variadic_default_value.test
@@ -0,0 +1,7 @@
+--TEST--
+macro variadic argument cannot have a default value
+--TEMPLATE--
+{% macro test(...rest = []) %}
+{% endmacro %}
+--EXCEPTION--
+Twig\Error\SyntaxError: The variadic argument "rest" in macro "test" cannot have a default value in "index.twig" at line 2.
diff --git a/tests/Fixtures/macros/variadic_duplicate.test b/tests/Fixtures/macros/variadic_duplicate.test
new file mode 100644
index 00000000000..e151824d0cf
--- /dev/null
+++ b/tests/Fixtures/macros/variadic_duplicate.test
@@ -0,0 +1,7 @@
+--TEST--
+macro variadic argument cannot reuse another argument name
+--TEMPLATE--
+{% macro test(a, ...a) %}
+{% endmacro %}
+--EXCEPTION--
+Twig\Error\SyntaxError: The variadic argument "a" in macro "test" cannot have the same name as another argument in "index.twig" at line 2.
diff --git a/tests/Fixtures/macros/variadic_invalid_name.test b/tests/Fixtures/macros/variadic_invalid_name.test
new file mode 100644
index 00000000000..565cd8d0559
--- /dev/null
+++ b/tests/Fixtures/macros/variadic_invalid_name.test
@@ -0,0 +1,7 @@
+--TEST--
+macro variadic argument cannot use a reserved literal name
+--TEMPLATE--
+{% macro test(...true) %}
+{% endmacro %}
+--EXCEPTION--
+Twig\Error\SyntaxError: You cannot assign a value to "true" in "index.twig" at line 2.
diff --git a/tests/Fixtures/macros/variadic_named.test b/tests/Fixtures/macros/variadic_named.test
new file mode 100644
index 00000000000..5c640f65572
--- /dev/null
+++ b/tests/Fixtures/macros/variadic_named.test
@@ -0,0 +1,16 @@
+--TEST--
+macro explicit variadic argument also collects named arguments
+--TEMPLATE--
+{% from _self import attributes %}
+
+{% macro attributes(...attrs) -%}
+ {% for name, value in attrs %}{{ name }}={{ value }}{{ not loop.last ? ' ' }}{% endfor %}
+{%- endmacro %}
+
+{{ attributes(id: "main", class: "box") }}
+{{ attributes(blocks: "list", varargs: "extra") }}
+--DATA--
+return []
+--EXPECT--
+id=main class=box
+blocks=list varargs=extra
diff --git a/tests/Fixtures/macros/variadic_not_last.test b/tests/Fixtures/macros/variadic_not_last.test
new file mode 100644
index 00000000000..e56353d11b6
--- /dev/null
+++ b/tests/Fixtures/macros/variadic_not_last.test
@@ -0,0 +1,7 @@
+--TEST--
+macro variadic argument must be the last one
+--TEMPLATE--
+{% macro test(a, ...rest, b) %}
+{% endmacro %}
+--EXCEPTION--
+Twig\Error\SyntaxError: The variadic argument "rest" in macro "test" must be the last one in "index.twig" at line 2.
diff --git a/tests/Fixtures/macros/variadic_reserved_name.test b/tests/Fixtures/macros/variadic_reserved_name.test
new file mode 100644
index 00000000000..7ddc628ba88
--- /dev/null
+++ b/tests/Fixtures/macros/variadic_reserved_name.test
@@ -0,0 +1,14 @@
+--TEST--
+macro with an explicit variadic argument using a reserved name
+--TEMPLATE--
+{% from _self import row %}
+
+{% macro row(label, ...context) -%}
+ {{ label }}:{% for key, value in context %} {{ key }}={{ value }}{% endfor %}
+{%- endmacro %}
+
+{{ row("attrs", 1, id: "main") }}
+--DATA--
+return []
+--EXPECT--
+attrs: 0=1 id=main
diff --git a/tests/Fixtures/tags/macro/basic.test b/tests/Fixtures/tags/macro/basic.test
index ae090f9a069..ddf234672fa 100644
--- a/tests/Fixtures/tags/macro/basic.test
+++ b/tests/Fixtures/tags/macro/basic.test
@@ -6,7 +6,7 @@
{{ macros.input('username') }}
{{ macros.input('password', null, 'password', 1) }}
-{% macro input(name, value, type, size) %}
+{% macro input(name, value = null, type = null, size = null) %}
{% endmacro %}
--DATA--
diff --git a/tests/Fixtures/tags/macro/external.test b/tests/Fixtures/tags/macro/external.test
index b28ca19f029..28e67b76cc2 100644
--- a/tests/Fixtures/tags/macro/external.test
+++ b/tests/Fixtures/tags/macro/external.test
@@ -6,7 +6,7 @@
{{ forms.input('username') }}
{{ forms.input('password', null, 'password', 1) }}
--TEMPLATE(forms.twig)--
-{% macro input(name, value, type, size) %}
+{% macro input(name, value = null, type = null, size = null) %}
{% endmacro %}
--DATA--
diff --git a/tests/Fixtures/tags/macro/global.test b/tests/Fixtures/tags/macro/global.test
index 832740eac56..11e0f99fb9c 100644
--- a/tests/Fixtures/tags/macro/global.test
+++ b/tests/Fixtures/tags/macro/global.test
@@ -6,7 +6,7 @@
{{ foo('foo') }}
{{ foo() }}
--TEMPLATE(forms.twig)--
-{% macro foo(name) %}{{ name|default('foo') }}{{ global }}{% endmacro %}
+{% macro foo(name = null) %}{{ name|default('foo') }}{{ global }}{% endmacro %}
--DATA--
return []
--EXPECT--
diff --git a/tests/Fixtures/tags/macro/named_arguments.test b/tests/Fixtures/tags/macro/named_arguments.test
index 58bd15b2024..89e7c5643a6 100644
--- a/tests/Fixtures/tags/macro/named_arguments.test
+++ b/tests/Fixtures/tags/macro/named_arguments.test
@@ -5,7 +5,7 @@
{{ forms.input(size: 10, name: 'username') }}
-{% macro input(name, value, type, size) %}
+{% macro input(name, value = null, type = null, size = null) %}
{% endmacro %}
--DATA--
diff --git a/tests/Fixtures/tags/macro/self_import.test b/tests/Fixtures/tags/macro/self_import.test
index ca3157dd884..eae97618bfc 100644
--- a/tests/Fixtures/tags/macro/self_import.test
+++ b/tests/Fixtures/tags/macro/self_import.test
@@ -6,7 +6,7 @@
{{ forms.input('username') }}
{{ forms.input('password', null, 'password', 1) }}
-{% macro input(name, value, type, size) %}
+{% macro input(name, value = null, type = null, size = null) %}
{% endmacro %}
--DATA--
diff --git a/tests/Node/Expression/MacroReferenceTest.php b/tests/Node/Expression/MacroReferenceTest.php
index b823cf75b46..def53f0147a 100644
--- a/tests/Node/Expression/MacroReferenceTest.php
+++ b/tests/Node/Expression/MacroReferenceTest.php
@@ -11,7 +11,6 @@
namespace Twig\Tests\Node\Expression;
-use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
@@ -22,28 +21,6 @@
class MacroReferenceTest extends TestCase
{
- /**
- * @dataProvider provideInvalidMacroNames
- */
- #[DataProvider('provideInvalidMacroNames')]
- public function testConstructorRejectsNonIdentifierName(string $name)
- {
- $this->expectException(\LogicException::class);
- $this->expectExceptionMessage(\sprintf('Macro name "%s" is not a valid PHP identifier.', $name));
-
- new MacroReferenceExpression(new TemplateVariable('foo', 1), $name, new ArrayExpression([], 1), 1);
- }
-
- public static function provideInvalidMacroNames(): iterable
- {
- yield 'empty' => [''];
- yield 'starts with digit' => ['1foo'];
- yield 'contains space' => ['foo bar'];
- yield 'contains semicolon' => ['foo;bar'];
- 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);
@@ -52,7 +29,7 @@ public function testConstructorAcceptsAnExpressionAsName()
$this->assertNull($node->getAttribute('name'));
}
- public function testDynamicNamePrefixesMacroAtRuntime()
+ public function testDynamicNameResolvesMacroAtRuntime()
{
$env = new Environment(new ArrayLoader());
$compiler = new \Twig\Compiler($env);
@@ -65,7 +42,8 @@ public function testDynamicNamePrefixesMacroAtRuntime()
);
$compiler->compile($node);
- $this->assertStringContainsString("getTemplateForMacro(\$_v0 = 'macro_'.", $compiler->getSource());
- $this->assertStringContainsString('->{$_v0}(...', $compiler->getSource());
+ $this->assertStringContainsString('->callMacro(', $compiler->getSource());
+ $this->assertStringContainsString('($context["name"] ?? null), [], $context, 1, $this->getSourceContext())', $compiler->getSource());
+ $this->assertStringNotContainsString("'macro_'.", $compiler->getSource());
}
}
diff --git a/tests/Node/MacroTest.php b/tests/Node/MacroTest.php
index 4f5b4e02bae..fc069922a4f 100644
--- a/tests/Node/MacroTest.php
+++ b/tests/Node/MacroTest.php
@@ -20,6 +20,7 @@
* file that was distributed with this source code.
*/
+use PHPUnit\Framework\Attributes\DataProvider;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Node\BodyNode;
@@ -44,7 +45,7 @@ public function testConstructor()
$this->assertEquals('foo', $node->getAttribute('name'));
}
- public static function provideTests(): iterable
+ private static function createNode(): MacroNode
{
$arguments = new ArrayExpression([
new LocalVariable('foo', 1),
@@ -56,12 +57,37 @@ public static function provideTests(): iterable
], 1);
$body = new BodyNode([new TextNode('foo', 1)]);
- $node = new MacroNode('foo', $body, $arguments, 1);
- yield 'with use_yield = true' => [$node, << [self::createNode(), ''];
+ }
+
+ /**
+ * @dataProvider provideMacroFactoryTests
+ */
+ #[DataProvider('provideMacroFactoryTests')]
+ public function testCompileMacroFactory(MacroNode $node, string $expected, Environment $environment): void
+ {
+ $compiler = $this->getCompiler($environment);
+ // compile() is a no-op but resets the compiler state (source, line) so the
+ // factory's debug info is emitted as it would be inside loadDeclaredMacros().
+ $compiler->compile($node);
+ $node->compileMacroFactory($compiler);
+
+ $this->assertSame($expected, trim($compiler->getSource()));
+ }
+
+ public static function provideMacroFactoryTests(): iterable
+ {
+ yield 'with use_yield = true' => [self::createNode(), <<macros;
\$context = [
"foo" => \$foo,
@@ -76,14 +102,13 @@ public function macro_foo(\$foo = null, \$bar = "Foo", \$_underscore = null, ...
yield "foo";
yield from [];
})(), false))) ? '' : new Markup(\$tmp, \$this->env->getCharset());
-}
+}, ["foo" => true, "bar" => true, "_underscore" => true], false)
EOF, new Environment(new ArrayLoader(), ['use_yield' => true]),
];
- yield 'with use_yield = false' => [$node, << [self::createNode(), <<macros;
\$context = [
"foo" => \$foo,
@@ -98,7 +123,7 @@ public function macro_foo(\$foo = null, \$bar = "Foo", \$_underscore = null, ...
yield "foo";
yield from [];
})())) ? '' : new Markup(\$tmp, \$this->env->getCharset());
-}
+}, ["foo" => true, "bar" => true, "_underscore" => true], false)
EOF, new Environment(new ArrayLoader(), ['use_yield' => false]),
];
}
diff --git a/tests/Node/MacrosTest.php b/tests/Node/MacrosTest.php
new file mode 100644
index 00000000000..4c72bbd2467
--- /dev/null
+++ b/tests/Node/MacrosTest.php
@@ -0,0 +1,85 @@
+expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Using "Twig\Node\TextNode" for the macro "foo" of "Twig\Node\MacrosNode" is not supported. You must pass a "Twig\Node\MacroNode" instance.');
+
+ new MacrosNode(['foo' => new TextNode('foo', 1)]);
+ }
+
+ public function testItRejectsReplacingAMacroWithANonMacroNode()
+ {
+ $macros = new MacrosNode(['foo' => self::createMacro()]);
+
+ $this->expectException(\LogicException::class);
+ $this->expectExceptionMessage('A "Twig\Node\MacrosNode" can only contain "Twig\Node\MacroNode" nodes; replacing the macro "foo" with a "Twig\Node\TextNode" node is not supported.');
+
+ $macros->setNode('foo', new TextNode('foo', 1));
+ }
+
+ private static function createMacro(): MacroNode
+ {
+ $arguments = new ArrayExpression([
+ new LocalVariable('foo', 1),
+ new ConstantExpression(null, 1),
+ ], 1);
+
+ return new MacroNode('foo', new BodyNode([new TextNode('foo', 1)]), $arguments, 1);
+ }
+
+ public static function provideTests(): iterable
+ {
+ yield 'without macros, no method is compiled' => [new MacrosNode(), ''];
+
+ $macro = self::createMacro();
+
+ yield 'with macros, the registry method is compiled' => [new MacrosNode(['foo' => $macro]), << new \\Twig\\TwigMacro("foo", function (\$foo = null, ...\$varargs): string|Markup {
+ // line 1
+ \$macros = \$this->macros;
+ \$context = [
+ "foo" => \$foo,
+ "varargs" => \$varargs,
+ ] + \$this->env->getGlobals();
+
+ \$blocks = [];
+
+ return ('' === \$tmp = implode('', iterator_to_array((function () use (&\$context, \$macros, \$blocks) {
+ yield "foo";
+ yield from [];
+ })(), false))) ? '' : new Markup(\$tmp, \$this->env->getCharset());
+ }, ["foo" => true], false),
+ ];
+}
+EOF, new Environment(new ArrayLoader(), ['use_yield' => true]),
+ ];
+ }
+}
diff --git a/tests/TwigMacroTest.php b/tests/TwigMacroTest.php
new file mode 100644
index 00000000000..46205850279
--- /dev/null
+++ b/tests/TwigMacroTest.php
@@ -0,0 +1,219 @@
+ '', ['name' => false]);
+
+ try {
+ $macro->callLegacy(['name' => 'a', 0 => 'b'], new Source('', 'index.twig'), 7);
+
+ $this->fail('Expected a RuntimeError to be thrown.');
+ } catch (RuntimeError $e) {
+ $this->assertSame('Positional arguments cannot be used after named arguments for macro "input" in "index.twig" at line 7.', $e->getMessage());
+ }
+ }
+
+ public function testLegacyCallDoesNotReportDeprecationsForACallThatThrows()
+ {
+ $macro = new TwigMacro('input', static fn () => '', ['name' => false]);
+
+ $deprecations = $this->collectDeprecations(function () use ($macro) {
+ try {
+ $macro->callLegacy(['unknown' => 'a', 0 => 'b'], new Source('', 'index.twig'), 7);
+
+ $this->fail('Expected a RuntimeError to be thrown.');
+ } catch (RuntimeError $e) {
+ $this->assertSame('Positional arguments cannot be used after named arguments for macro "input" in "index.twig" at line 7.', $e->getMessage());
+ }
+ });
+
+ $this->assertSame([], $deprecations);
+ }
+
+ public function testLegacyCallRejectsAnArgumentDefinedTwice()
+ {
+ $macro = new TwigMacro('input', static fn () => '', ['name' => false]);
+
+ try {
+ $macro->callLegacy([0 => 'a', 'name' => 'b'], new Source('', 'index.twig'), 7);
+
+ $this->fail('Expected a RuntimeError to be thrown.');
+ } catch (RuntimeError $e) {
+ $this->assertSame('Argument "name" is defined twice for macro "input" in "index.twig" at line 7.', $e->getMessage());
+ }
+ }
+
+ /**
+ * The body closures mirror what MacroNode compiles on 3.x: every declared
+ * argument becomes a defaulted parameter (reserved names prefixed) and a
+ * trailing variadic bucket collects the extra arguments.
+ *
+ * @dataProvider provideLegacyCalls
+ */
+ #[DataProvider('provideLegacyCalls')]
+ public function testLegacyCallIsLenientButReportsFutureErrors(array $signature, bool $variadic, \Closure $bodyFactory, array $arguments, array $expectedArguments, array $expectedDeprecations)
+ {
+ $captured = null;
+ $macro = new TwigMacro('test', $bodyFactory($captured), $signature, $variadic);
+
+ $deprecations = $this->collectDeprecations(static function () use ($macro, $arguments) {
+ $macro->callLegacy($arguments, new Source('', 'index.twig'), 7);
+ });
+
+ $this->assertSame($expectedArguments, $captured);
+ $this->assertSame($expectedDeprecations, $deprecations);
+ }
+
+ public static function provideLegacyCalls(): iterable
+ {
+ $nameOnly = static function (&$captured) {
+ return static function ($name = null, ...$varargs) use (&$captured) {
+ $captured = [$name, $varargs];
+
+ return '';
+ };
+ };
+
+ $nameValueType = static function (&$captured) {
+ return static function ($name = null, $value = 'v', $type = 't', ...$varargs) use (&$captured) {
+ $captured = [$name, $value, $type, $varargs];
+
+ return '';
+ };
+ };
+
+ yield 'missing argument without a default is lenient and deprecated' => [
+ ['name' => false, 'value' => true],
+ false,
+ static function (&$captured) {
+ return static function ($name = null, $value = null, ...$varargs) use (&$captured) {
+ $captured = [$name, $value, $varargs];
+
+ return '';
+ };
+ },
+ [],
+ [null, null, []],
+ ['Since twig/twig 3.29: Not passing a value for the "name" argument of macro "test" is deprecated and the argument will be required in Twig 4.0; give it a default value in the macro definition or pass a value when calling it (in "index.twig" at line 7).'],
+ ];
+
+ yield 'extra positional argument is lenient and deprecated' => [
+ ['name' => false],
+ false,
+ $nameOnly,
+ [0 => 'a', 1 => 'b'],
+ ['a', ['b']],
+ ['Since twig/twig 3.29: Passing more arguments than the macro "test" accepts is deprecated and will throw in Twig 4.0; declare a variadic argument ("...name") in the macro definition to accept extra arguments (in "index.twig" at line 7).'],
+ ];
+
+ yield 'unknown named argument is lenient and deprecated' => [
+ ['name' => false],
+ false,
+ $nameOnly,
+ ['name' => 'a', 'extra' => 'b'],
+ ['a', ['extra' => 'b']],
+ ['Since twig/twig 3.29: Passing the unknown named argument "extra" to the macro "test" is deprecated and will throw in Twig 4.0; declare a variadic argument ("...name") in the macro definition to accept it (in "index.twig" at line 7).'],
+ ];
+
+ yield 'null named argument value satisfies a required argument' => [
+ ['name' => false],
+ false,
+ $nameOnly,
+ ['name' => null],
+ [null, []],
+ [],
+ ];
+
+ yield 'a reserved argument name maps onto its prefixed parameter' => [
+ ['name' => true, 'blocks' => true],
+ false,
+ static function (&$captured) {
+ return static function ($name = null, $͜blocks = 'default', ...$varargs) use (&$captured) {
+ $captured = [$name, $͜blocks, $varargs];
+
+ return '';
+ };
+ },
+ ['blocks' => 'value'],
+ [null, 'value', []],
+ [],
+ ];
+
+ yield 'skipped optional arguments before a named argument get their defaults' => [
+ ['name' => false, 'value' => true, 'type' => true],
+ false,
+ $nameValueType,
+ ['name' => 'a', 'type' => 'x'],
+ ['a', 'v', 'x', []],
+ [],
+ ];
+
+ yield 'positional and named arguments mix under the legacy call path' => [
+ ['name' => false, 'value' => true, 'type' => true],
+ false,
+ $nameValueType,
+ [0 => 'a', 'type' => 'x'],
+ ['a', 'v', 'x', []],
+ [],
+ ];
+
+ yield 'known named arguments bind to their parameter and unknown ones fall into the variadic bucket' => [
+ ['name' => false, 'value' => true, 'type' => true],
+ true,
+ $nameValueType,
+ [0 => 'a', 'type' => 'submit', 'extra' => 'value'],
+ ['a', 'v', 'submit', ['extra' => 'value']],
+ [],
+ ];
+
+ yield 'a variadic macro reports nothing' => [
+ ['name' => false],
+ true,
+ $nameOnly,
+ [0 => 'a', 1 => 'b', 'extra' => 'c'],
+ ['a', ['b', 'extra' => 'c']],
+ [],
+ ];
+ }
+
+ private function collectDeprecations(callable $fn): array
+ {
+ $deprecations = [];
+ set_error_handler(static function ($type, $message) use (&$deprecations) {
+ if (\E_USER_DEPRECATED === $type) {
+ $deprecations[] = $message;
+
+ return true;
+ }
+
+ return false;
+ });
+
+ try {
+ $fn();
+ } finally {
+ restore_error_handler();
+ }
+
+ return $deprecations;
+ }
+}