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 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 %}']); $namespace = $template->getMacroNamespace(); $this->assertSame(['__construct', 'has', 'call'], array_map(static fn (\ReflectionMethod $method): string => $method->getName(), (new \ReflectionClass($namespace))->getMethods(\ReflectionMethod::IS_PUBLIC))); $this->assertTrue($namespace->has('greet', [])); $this->assertSame('Hi World', (string) $namespace->call('greet', ['World'], [], 1, new Source('', 'index'))); $this->assertSame($namespace, $template->getMacroNamespace()); $this->assertInstanceOf(MacroNamespace::class, $namespace); } public function testMacroNamespaceDoesNotBenefitFromTheTemplateSandboxExemption(): void { $policy = new SecurityPolicy(); $this->expectException(SecurityNotAllowedMethodError::class); $this->expectExceptionMessage('Calling "call" method on a "Twig\\MacroNamespace" object is not allowed.'); $policy->checkMethodAllowed($this->load(['index' => ''])->getMacroNamespace(), 'call'); } public function testCallMacroLooksMacrosUpInParentTemplates(): void { $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 testCallMacroRejectsExtraArgumentsAtTheCallSite(): void { $twig = new Environment(new ArrayLoader([ 'index' => "{% from _self import greet %}\n{% macro greet(name) %}{% endmacro %}\n{{ greet('a', 'b') }}", ])); $this->expectException(RuntimeError::class); $this->expectExceptionMessage('Too many arguments for macro "greet" in "index" at line 3.'); $twig->render('index'); } public function testMacroNamesAreCaseSensitive(): void { $template = $this->load(['index' => '{% macro greet(name = "") %}Hi {{ name }}{% endmacro %}']); $namespace = $template->getMacroNamespace(); $this->assertTrue($namespace->has('greet', [])); $this->assertFalse($namespace->has('GREET', [])); $this->expectException(RuntimeError::class); $this->expectExceptionMessage('Macro "GREET" is not defined in template "index"'); $namespace->call('GREET', ['World'], [], 1, new Source('', 'index')); } public function testCallMacroDoesNotResolveCaseMismatchedMacrosInParentTemplates(): void { $template = $this->load([ 'index' => '{% extends "parent" %}', 'parent' => '{% macro greet(name = "") %}Hi {{ name }}{% endmacro %}', ]); $this->expectException(RuntimeError::class); $this->expectExceptionMessage('Macro "Greet" is not defined in template "index"'); $template->getMacroNamespace()->call('Greet', ['World'], [], 1, new Source('', 'index')); } public function testCallMacroDoesNotResolveALegacyPrefixedName(): void { $template = $this->load(['index' => '{% macro greet(name = "") %}Hi {{ name }}{% endmacro %}']); $this->assertFalse($template->getMacroNamespace()->has('macro_greet', [])); $this->expectException(RuntimeError::class); $this->expectExceptionMessage('Macro "macro_greet" is not defined in template "index"'); $template->getMacroNamespace()->call('macro_greet', ['World'], [], 1, new Source('', 'index')); } public function testCallMacroSupportsAMacroNamedWithThePrefix(): void { $template = $this->load(['index' => '{% macro macro_greet(name = "") %}Prefixed {{ name }}{% endmacro %}{% macro greet(name = "") %}Bare {{ name }}{% endmacro %}']); $this->assertTrue($template->getMacroNamespace()->has('macro_greet', [])); $this->assertSame('Prefixed World', (string) $template->getMacroNamespace()->call('macro_greet', ['World'], [], 1, new Source('', 'index'))); } public function testCallMacroThrowsForAnUnknownMacro(): void { $template = $this->load(['index' => 'no macro here']); $this->expectException(RuntimeError::class); $this->expectExceptionMessage('Macro "missing" is not defined in template "index"'); $template->getMacroNamespace()->call('missing', [], [], 1, new Source('', 'index')); } private function callMacro(Template $template, string $name, array $arguments): mixed { return $template->getMacroNamespace()->call($name, $arguments, [], 1, new Source('', 'index')); } private function load(array $templates): Template { $twig = new Environment(new ArrayLoader($templates)); return $twig->load('index')->unwrap(); } }