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; + } +}