Merge branch '3.x' into 4.x

* 3.x:
  Nested macro imports

# Conflicts:
#	CHANGELOG
#	src/MacroNamespace.php
#	src/Node/MacrosNode.php
#	src/Parser.php
#	tests/Extension/SandboxStateChangeTest.php
#	tests/Node/MacrosTest.php
This commit is contained in:
Fabien Potencier
2026-08-03 10:34:02 +02:00
17 changed files with 392 additions and 25 deletions
+2
View File
@@ -101,6 +101,8 @@ What? Implementation difficulty? How often? When?
*operator* simple rare Values transformation
========== ========================== ========== =========================
.. _environment-globals:
Globals
-------
+27
View File
@@ -150,6 +150,33 @@ macros are available in all blocks and other macros defined in the current
template, but they are not available in included templates or child templates;
you need to explicitly re-import macros in each template.
A macro can use the imports declared at the top level of its own template::
{# forms.twig #}
{% import "fields.twig" as fields %}
{% macro input(name) %}
{{ fields.text(name) }}
{% endmacro %}
This also works when the macro is called from another template. For such an
import to be available inside macros, it must follow two rules:
* It must be declared at the top level of the template, not nested in another
tag like ``if`` or ``for``;
* The imported template name must be a literal string or an expression that
only depends on :ref:`global variables <environment-globals>`; it cannot
use other variables.
To use a dynamic template name, pass it as a macro argument and import it
inside the macro body::
{% macro input(name, theme) %}
{% import theme as fields %}
{{ fields.text(name) }}
{% endmacro %}
Imported macros are not available in the body of ``embed`` tags, you need
to explicitly re-import macros inside the tag.
+20 -1
View File
@@ -23,7 +23,8 @@ final class MacroNamespace
*/
public function __construct(
private Template $template,
private array $macros,
private array $macros = [],
private ?\Closure $importsLoader = null,
) {
}
@@ -60,6 +61,7 @@ final class MacroNamespace
}
$this->template->ensureSecurityChecked();
$this->loadImports();
return $this->macros[$name];
}
@@ -78,6 +80,23 @@ final class MacroNamespace
}
}
private function loadImports(): void
{
if (null === $loader = $this->importsLoader) {
return;
}
// clear before loading so that circular imports don't recurse infinitely
$this->importsLoader = null;
try {
$loader();
} catch (\Throwable $e) {
$this->importsLoader = $loader;
throw $e;
}
}
private function getParent(array $context): ?self
{
if (!$parent = $this->template->getParent($context)) {
+80
View File
@@ -0,0 +1,80 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Node;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
use Twig\Node\Expression\Variable\AssignMacroVariable;
/**
* Compiles the lazy loader for the top-level imports used by the macros
* declared in a template.
*
* @internal
*/
#[YieldReady]
final class MacroImportsNode extends Node
{
public function __construct(Node $body)
{
$imports = [];
$this->collectTopLevelImports($body, $imports);
parent::__construct($imports);
}
public function compile(Compiler $compiler): void
{
$compiler
->raw("function (): void {\n")
->indent()
->write("if (\$this->skipLazyMacroImports) {\n")
->indent()
->write("return;\n")
->outdent()
->write("}\n\n")
->write("\$this->ensureSecurityChecked();\n")
->write("\$context = \$this->env->getGlobals();\n")
->write("\$macros = \$this->macros;\n")
;
foreach ($this as $import) {
$compiler->subcompile($import);
}
$compiler
->outdent()
->write('}')
;
}
/**
* @param list<ImportNode> $imports
*/
private function collectTopLevelImports(Node $node, array &$imports): void
{
if ($node instanceof ImportNode) {
$var = $node->getNode('var');
if ($var instanceof AssignMacroVariable && $var->getAttribute('global')) {
$imports[] = $node;
}
return;
}
if (!$node instanceof BodyNode && !$node instanceof Nodes) {
return;
}
foreach ($node as $child) {
$this->collectTopLevelImports($child, $imports);
}
}
}
+48 -7
View File
@@ -17,7 +17,7 @@ use Twig\Compiler;
/**
* Represents the macros declared in a template.
*
* It compiles to the method returning the macro registry of the template.
* It compiles the macro namespace of the template.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
@@ -27,7 +27,7 @@ final class MacrosNode extends Node
/**
* @param array<string, MacroNode> $macros
*/
public function __construct(array $macros = [])
public function __construct(array $macros = [], private ?MacroImportsNode $imports = null)
{
foreach ($macros as $name => $macro) {
if (!$macro instanceof MacroNode) {
@@ -38,6 +38,14 @@ final class MacrosNode extends Node
parent::__construct($macros);
}
public function __clone()
{
parent::__clone();
if (null !== $this->imports) {
$this->imports = clone $this->imports;
}
}
public function setNode(string|int $name, Node $node): void
{
if (!$node instanceof MacroNode) {
@@ -47,16 +55,51 @@ final class MacrosNode extends Node
parent::setNode($name, $node);
}
/**
* @internal
*/
public function hasImports(): bool
{
return \count($this) && null !== $this->imports && \count($this->imports);
}
public function compile(Compiler $compiler): void
{
if (!\count($this)) {
return;
}
$compiler->write("private ?MacroNamespace \$macroNamespace = null;\n");
if ($this->hasImports()) {
$compiler->write("private bool \$skipLazyMacroImports = false;\n");
}
$compiler
->write("protected function loadDeclaredMacros(): array\n", "{\n")
->raw("\n")
->write("public function getMacroNamespace(): MacroNamespace\n", "{\n")
->indent()
->write("return [\n")
->write('return $this->macroNamespace ??= new MacroNamespace($this, ')
;
$this->compileMacros($compiler);
if ($this->hasImports()) {
$compiler
->raw(', ')
->subcompile($this->imports)
;
}
$compiler
->raw(");\n")
->outdent()
->write("}\n\n")
;
}
private function compileMacros(Compiler $compiler): void
{
$compiler
->raw("[\n")
->indent()
;
@@ -73,9 +116,7 @@ final class MacrosNode extends Node
$compiler
->outdent()
->write("];\n")
->outdent()
->write("}\n\n")
->write(']')
;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Node;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
/**
* @internal
*/
#[YieldReady]
final class SkipLazyMacroImportsNode extends Node
{
public function compile(Compiler $compiler): void
{
$compiler->write("\$this->skipLazyMacroImports = true;\n");
}
}
+10 -2
View File
@@ -25,12 +25,14 @@ use Twig\Node\BodyNode;
use Twig\Node\EmptyNode;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\Variable\AssignMacroVariable;
use Twig\Node\MacroImportsNode;
use Twig\Node\MacroNode;
use Twig\Node\MacrosNode;
use Twig\Node\ModuleNode;
use Twig\Node\Node;
use Twig\Node\Nodes;
use Twig\Node\PrintNode;
use Twig\Node\SkipLazyMacroImportsNode;
use Twig\Node\TextNode;
use Twig\NodeVisitor\NodeVisitorInterface;
use Twig\TokenParser\TokenParserInterface;
@@ -135,11 +137,12 @@ class Parser
$body = $this->cleanupBodyForChildTemplates($body);
}
$body = new BodyNode([$body]);
$node = new ModuleNode(
new BodyNode([$body]),
$body,
$this->parent,
$this->blocks ? new Nodes($this->blocks) : new EmptyNode(),
new MacrosNode($this->macros),
new MacrosNode($this->macros, new MacroImportsNode($body)),
$this->traits ? new Nodes($this->traits) : new EmptyNode(),
$this->embeddedTemplates ? new Nodes($this->embeddedTemplates) : new EmptyNode(),
$stream->getSourceContext(),
@@ -152,6 +155,11 @@ class Parser
*/
$node = $traverser->traverse($node);
$macros = $node->getNode('macros');
if ($macros instanceof MacrosNode && $macros->hasImports()) {
$node->setNode('display_start', new Nodes([new SkipLazyMacroImportsNode(), $node->getNode('display_start')]));
}
// restore previous stack so previous parse() call can resume working
foreach (array_pop($this->stack) as $key => $val) {
$this->$key = $val;
+1 -9
View File
@@ -448,15 +448,7 @@ abstract class Template
*/
public function getMacroNamespace(): MacroNamespace
{
return $this->macroNamespace ??= new MacroNamespace($this, $this->loadDeclaredMacros());
}
/**
* @return array<string, TwigMacro>
*/
protected function loadDeclaredMacros(): array
{
return [];
return $this->macroNamespace ??= new MacroNamespace($this);
}
/**
+74
View File
@@ -13,6 +13,7 @@ namespace Twig\Tests;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Loader\ArrayLoader;
use Twig\MacroNamespace;
@@ -20,6 +21,7 @@ use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityPolicy;
use Twig\Source;
use Twig\Template;
use Twig\TwigFunction;
class CallMacroTest extends TestCase
{
@@ -33,6 +35,78 @@ class CallMacroTest extends TestCase
$this->assertSame('Hi World', (string) $this->callMacro($template, 'greet', ['name' => 'World', 'greeting' => 'Hi']));
}
public function testNestedMacroImportsResolveAgainstGlobals(): void
{
$twig = new Environment(new ArrayLoader([
'index' => '{% import "outer" as outer %}{{ outer.render() }}',
'outer' => '{% import macro_template as macros %}{% macro render() %}{{ macros.render() }}{% endmacro %}',
'first' => '{% macro render() %}first{% endmacro %}',
]));
$twig->addGlobal('macro_template', 'first');
$this->assertSame('first', $twig->render('index', []));
}
public function testNestedMacroImportsCannotUseTheImportingContext(): void
{
$twig = new Environment(new ArrayLoader([
'index' => '{% import "outer" as outer %}{{ outer.render() }}',
'outer' => '{% import macro_template as macros %}{% macro render() %}{{ macros.render() }}{% endmacro %}',
'first' => '{% macro render() %}first{% endmacro %}',
]), ['strict_variables' => true]);
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('Variable "macro_template" does not exist');
$twig->render('index', ['macro_template' => 'first']);
}
public function testNestedMacroImportsAreInitializedOncePerTemplate(): void
{
$twig = new Environment(new ArrayLoader([
'index' => '{% import "outer" as outer %}{{ outer.render() }}{{ outer.render() }}',
'outer' => '{% import pick() as macros %}{% macro render() %}{{ macros.render() }}{% endmacro %}',
'first' => '{% macro render() %}first{% endmacro %}',
]));
$calls = 0;
$twig->addFunction(new TwigFunction('pick', static function () use (&$calls): string {
++$calls;
return 'first';
}));
$this->assertSame('firstfirst', $twig->render('index', []));
$this->assertSame(1, $calls);
}
public function testFailedNestedMacroImportsFailTheSameWayOnRetry(): void
{
$twig = new Environment(new ArrayLoader([
'index' => '{% import "outer" as outer %}{{ outer.render() }}',
'outer' => '{% import "missing" as macros %}{% macro render() %}{{ macros.render() }}{% endmacro %}',
]));
foreach ([1, 2] as $attempt) {
try {
$twig->render('index', []);
$this->fail('Expected LoaderError');
} catch (LoaderError $e) {
$this->assertStringContainsString('Template "missing" is not defined', $e->getMessage(), "Attempt $attempt");
}
}
}
public function testRenderTimeImportsKeepUsingTheRenderContext(): void
{
$twig = new Environment(new ArrayLoader([
'index' => '{% include "outer" %}{% import "outer" as outer %}{{ outer.render() }}',
'outer' => '{% import macro_template as macros %}{% macro render() %}{{ macros.render() }}{% endmacro %}',
'first' => '{% macro render() %}first{% endmacro %}',
]), ['strict_variables' => true]);
$this->assertSame('first', $twig->render('index', ['macro_template' => 'first']));
}
public function testMacroNamespaceOnlyExposesMacroOperations(): void
{
$template = $this->load(['index' => '{% macro greet(name) %}Hi {{ name }}{% endmacro %}']);
+2 -1
View File
@@ -20,6 +20,7 @@ use Twig\Sandbox\SecurityNotAllowedFunctionError;
use Twig\Sandbox\SecurityNotAllowedTagError;
use Twig\Sandbox\SecurityPolicy;
use Twig\Sandbox\SecurityPolicyInterface;
use Twig\TwigFunction;
/**
* Regression tests for the sandbox filter/tag/function allow-list bypass that
@@ -200,7 +201,7 @@ class SandboxStateChangeTest extends TestCase
);
[$twig] = $this->build($templates, $policy, true);
$evilCalls = 0;
$twig->addFunction(new \Twig\TwigFunction('evil', static function ($v) use (&$evilCalls) {
$twig->addFunction(new TwigFunction('evil', static function ($v) use (&$evilCalls) {
++$evilCalls;
return $v;
+23
View File
@@ -1038,6 +1038,29 @@ EOF
$this->assertEquals('<p>username</p>', $twig->load('index')->render([]));
}
public function testMacroImportExpressionIsCheckedBeforeExecution(): void
{
$evilCalls = 0;
$twig = $this->getEnvironment(true, [], [
'caller.twig' => '{% import "macros.twig" as macros %}{{ macros.render() }}',
'macros.twig' => '{% from evil() import render as dependency %}{% macro render() %}{{ dependency() }}{% endmacro %}',
'dependency.twig' => '{% macro render() %}ok{% endmacro %}',
], ['from', 'import', 'macro']);
$twig->addFunction(new TwigFunction('evil', static function () use (&$evilCalls): string {
++$evilCalls;
return 'dependency.twig';
}));
try {
$twig->render('caller.twig');
$this->fail('Expected SecurityNotAllowedFunctionError');
} catch (SecurityNotAllowedFunctionError $e) {
$this->assertSame('evil', $e->getFunctionName());
}
$this->assertSame(0, $evilCalls, 'The forbidden function must not be invoked before the security check runs.');
}
public function testSelfMacroReferenceWithStringLiteralDoesNotInjectPhp(): void
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ _self.(\'foo + 1; trigger_error("BAD-MACRO-REF") //\')() }}']);
@@ -0,0 +1,15 @@
--TEST--
Circular nested macro imports
--TEMPLATE--
{% import "macros1.twig" as macros %}
{{ macros.macro1() }}
--TEMPLATE(macros1.twig)--
{% import "macros2.twig" as macros %}
{% macro macro1() %}[{{ macros.macro2() }}]{% endmacro %}
--TEMPLATE(macros2.twig)--
{% import "macros1.twig" as macros %}
{% macro macro2() %}ok{% endmacro %}
--DATA--
return []
--EXPECT--
[ok]
@@ -0,0 +1,14 @@
--TEST--
A conditional macro import is not initialized outside its control flow
--TEMPLATE--
{% import "macros.twig" as macros %}
{{ macros.macro() }}
--TEMPLATE(macros.twig)--
{% if false %}
{% import "missing.twig" as unused %}
{% endif %}
{% macro macro() %}ok{% endmacro %}
--DATA--
return []
--EXPECT--
ok
@@ -0,0 +1,14 @@
--TEST--
"from" tag with a macro that calls a macro imported in another template
--TEMPLATE--
{% from "macros2.twig" import macro2 %}
{{ macro2() }}
--TEMPLATE(macros2.twig)--
{% from "macros1.twig" import macro1 %}
{% macro macro2() %}[{{ macro1() }}]{% endmacro %}
--TEMPLATE(macros1.twig)--
{% macro macro1() %}ok{% endmacro %}
--DATA--
return []
--EXPECT--
[ok]
@@ -0,0 +1,14 @@
--TEST--
"import" tag with a macro that calls a macro imported in another template
--TEMPLATE--
{% import "macros2.twig" as macros2 %}
{{ macros2.macro2() }}
--TEMPLATE(macros2.twig)--
{% import "macros1.twig" as macros1 %}
{% macro macro2() %}[{{ macros1.macro1() }}]{% endmacro %}
--TEMPLATE(macros1.twig)--
{% macro macro1() %}ok{% endmacro %}
--DATA--
return []
--EXPECT--
[ok]
@@ -0,0 +1,14 @@
--TEST--
Nested macro imports cannot use the importing template's context
--TEMPLATE--
{% from "macros2.twig" import macro2 %}
{{ macro2() }}
--TEMPLATE(macros2.twig)--
{% from macros_template import macro1 %}
{% macro macro2() %}[{{ macro1() }}]{% endmacro %}
--TEMPLATE(macros1.twig)--
{% macro macro1() %}ok{% endmacro %}
--DATA--
return ['macros_template' => 'macros1.twig']
--EXCEPTION--
Twig\Error\RuntimeError: Variable "macros_template" does not exist in "macros2.twig" at line 2.
+7 -5
View File
@@ -54,14 +54,16 @@ class MacrosTest extends NodeTestCase
public static function provideTests(): iterable
{
yield 'without macros, no method is compiled' => [new MacrosNode(), ''];
yield 'without macros, nothing is compiled' => [new MacrosNode(), ''];
$macro = self::createMacro();
yield 'with macros, the registry method is compiled' => [new MacrosNode(['foo' => $macro]), <<<EOF
protected function loadDeclaredMacros(): array
yield 'with macros, the namespace is compiled' => [new MacrosNode(['foo' => $macro]), <<<EOF
private ?MacroNamespace \$macroNamespace = null;
public function getMacroNamespace(): MacroNamespace
{
return [
return \$this->macroNamespace ??= new MacroNamespace(\$this, [
"foo" => new \\Twig\\TwigMacro("foo", function (\$foo = null): string|Markup {
// line 1
\$macros = \$this->macros;
@@ -76,7 +78,7 @@ protected function loadDeclaredMacros(): array
yield from [];
})(), false))) ? '' : new Markup(\$tmp, \$this->env->getCharset());
}, ["foo" => true], false),
];
]);
}
EOF, new Environment(new ArrayLoader(), ['use_yield' => true]),
];