Report the macro call parentheses deprecation once per call site and name the macro

This commit is contained in:
Fabien Potencier
2026-09-18 12:12:50 +02:00
parent b1fe79b610
commit 40d57c6445
4 changed files with 163 additions and 17 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
# 3.29.1 (2026-XX-XX)
* n/a
* Fix the deprecation about omitting parentheses when calling a macro being reported twice for the same call
* Add the macro name to the deprecation about omitting parentheses when calling a macro
# 3.29.0 (2026-09-18)
+23 -7
View File
@@ -33,7 +33,10 @@ use Twig\Node\TextNode;
final class CorrectnessNodeVisitor implements NodeVisitorInterface
{
private ?\WeakMap $rootNodes = null;
private ?\WeakMap $checkedMacroReferences = null;
/**
* @var array<string, true>
*/
private array $reportedMacroCallSites = [];
/**
* Stack of the output-wrapping tags ("if", "for", "set", ...) currently open;
* the top one is the nearest tag a "block" definition would be nested under.
@@ -94,7 +97,6 @@ final class CorrectnessNodeVisitor implements NodeVisitorInterface
{
$this->resetState();
$this->rootNodes = new \WeakMap();
$this->checkedMacroReferences = new \WeakMap();
$this->hasParent = $node->hasNode('parent');
foreach ($this->getRootNodes($node) as $n) {
@@ -108,7 +110,7 @@ final class CorrectnessNodeVisitor implements NodeVisitorInterface
private function resetState(): void
{
$this->rootNodes = null;
$this->checkedMacroReferences = null;
$this->reportedMacroCallSites = [];
$this->tagStack = [];
$this->hasParent = false;
$this->blockDepth = 0;
@@ -162,13 +164,27 @@ final class CorrectnessNodeVisitor implements NodeVisitorInterface
private function checkMacroCallParentheses(MacroReferenceExpression $node): void
{
if (isset($this->checkedMacroReferences[$node])) {
if ($node->hasCallParentheses() || $node->isDefinedTestEnabled()) {
return;
}
$this->checkedMacroReferences[$node] = true;
if (false === $node->hasCallParentheses() && !$node->isDefinedTestEnabled()) {
trigger_deprecation('twig/twig', '3.29', 'Omitting parentheses when calling a macro is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "%s" at line %d.', $node->getSourceContext()->getName(), $node->getTemplateLine());
$sourceName = $node->getSourceContext()->getName();
$line = $node->getTemplateLine();
// A dynamic macro name is only known at runtime.
$name = $node->getAttribute('name');
// A single call site can be visited more than once: "??" reuses its left node,
// while "?:" and the "default" filter put a clone of it in the compiled tree.
$callSite = $sourceName."\0".$line."\0".$name;
if (isset($this->reportedMacroCallSites[$callSite])) {
return;
}
$this->reportedMacroCallSites[$callSite] = true;
if (null === $name) {
trigger_deprecation('twig/twig', '3.29', 'Omitting parentheses when calling a macro is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "%s" at line %d.', $sourceName, $line);
} else {
trigger_deprecation('twig/twig', '3.29', 'Omitting parentheses when calling the macro "%s" is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "%s" at line %d.', $name, $sourceName, $line);
}
}
@@ -1,9 +1,9 @@
--TEST--
Omitting parentheses when calling macros is deprecated
--DEPRECATION--
Since twig/twig 3.29: Omitting parentheses when calling a macro is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index.twig" at line 4.
Since twig/twig 3.29: Omitting parentheses when calling the macro "hello" is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index.twig" at line 4.
Since twig/twig 3.29: Omitting parentheses when calling a macro is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index.twig" at line 5.
Since twig/twig 3.29: Omitting parentheses when calling a macro is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index.twig" at line 6.
Since twig/twig 3.29: Omitting parentheses when calling the macro "hello" is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index.twig" at line 6.
--TEMPLATE--
{% import _self as macros %}
{% set name = 'hello' %}
+136 -7
View File
@@ -165,11 +165,13 @@ EOF, 'index')));
*/
#[DataProvider('provideMacroTargetExpressionsWithoutParentheses')]
#[Group('legacy')]
public function testMacroTargetsWithoutParenthesesAreDeprecated(string $expression): void
public function testMacroTargetsWithoutParenthesesAreDeprecated(string $expression, ?string $macroName): void
{
$twig = new Environment(new ArrayLoader());
$this->expectDeprecation('Since twig/twig 3.29: Omitting parentheses when calling a macro is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index" at line 1.');
$this->expectDeprecation(null === $macroName
? 'Since twig/twig 3.29: Omitting parentheses when calling a macro is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index" at line 1.'
: \sprintf('Since twig/twig 3.29: Omitting parentheses when calling the macro "%s" is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index" at line 1.', $macroName));
$module = $twig->parse($twig->tokenize(new Source("{% import _self as macros %}{{ $expression }}", 'index')));
$macroReferences = [];
@@ -184,18 +186,120 @@ EOF, 'index')));
public static function provideMacroTargetExpressionsWithoutParentheses(): iterable
{
foreach (['_self', 'macros'] as $target) {
yield $target.' static without parentheses' => [$target.'.foo'];
yield $target.' grouped static without parentheses' => ['('.$target.'.foo)'];
yield $target.' dynamic without parentheses' => [$target.'.(name)'];
yield $target.' grouped dynamic without parentheses' => ['('.$target.'.(name))'];
yield $target.' static without parentheses' => [$target.'.foo', 'foo'];
yield $target.' grouped static without parentheses' => ['('.$target.'.foo)', 'foo'];
yield $target.' dynamic without parentheses' => [$target.'.(name)', null];
yield $target.' grouped dynamic without parentheses' => ['('.$target.'.(name))', null];
}
}
/**
* @dataProvider provideExpressionsReusingAMacroReference
*
* @group legacy
*/
#[DataProvider('provideExpressionsReusingAMacroReference')]
#[Group('legacy')]
public function testAMacroCallWithoutParenthesesIsReportedOncePerCallSite(string $expression): void
{
$twig = new Environment(new ArrayLoader());
$source = new Source("{% import _self as macros %}{{ $expression }}", 'index');
$deprecations = $this->collectDeprecations(static function () use ($twig, $source) {
$twig->parse($twig->tokenize($source));
});
$this->assertSame([
'Since twig/twig 3.29: Omitting parentheses when calling the macro "foo" is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index" at line 1.',
], $deprecations);
}
public static function provideExpressionsReusingAMacroReference(): iterable
{
yield 'null-coalescing operator' => ['macros.foo ?? "x"'];
yield 'elvis operator' => ['macros.foo ?: "x"'];
yield 'default filter' => ['name|default(macros.foo)'];
}
/**
* @group legacy
*/
#[Group('legacy')]
public function testEachMacroCallWithoutParenthesesIsReportedOnItsOwnLine(): void
{
$twig = new Environment(new ArrayLoader());
$source = new Source("{% import _self as macros %}{{ macros.foo ?: 'x' }}\n{{ macros.foo ?: 'x' }}", 'index');
$deprecations = $this->collectDeprecations(static function () use ($twig, $source) {
$twig->parse($twig->tokenize($source));
});
$this->assertSame([
'Since twig/twig 3.29: Omitting parentheses when calling the macro "foo" is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index" at line 1.',
'Since twig/twig 3.29: Omitting parentheses when calling the macro "foo" is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index" at line 2.',
], $deprecations);
}
/**
* @group legacy
*/
#[Group('legacy')]
public function testDistinctMacroCallsWithoutParenthesesOnTheSameLineAreBothReported(): void
{
$twig = new Environment(new ArrayLoader());
$source = new Source('{% import _self as macros %}{{ macros.foo }}{{ macros.bar }}', 'index');
$deprecations = $this->collectDeprecations(static function () use ($twig, $source) {
$twig->parse($twig->tokenize($source));
});
$this->assertSame([
'Since twig/twig 3.29: Omitting parentheses when calling the macro "foo" is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index" at line 1.',
'Since twig/twig 3.29: Omitting parentheses when calling the macro "bar" is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index" at line 1.',
], $deprecations);
}
/**
* @group legacy
*/
#[Group('legacy')]
public function testADynamicMacroCallWithoutParenthesesIsReportedWithoutAName(): void
{
$twig = new Environment(new ArrayLoader());
$source = new Source('{% import _self as macros %}{{ macros.(name) ?: "x" }}', 'index');
$deprecations = $this->collectDeprecations(static function () use ($twig, $source) {
$twig->parse($twig->tokenize($source));
});
$this->assertSame([
'Since twig/twig 3.29: Omitting parentheses when calling a macro is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index" at line 1.',
], $deprecations);
}
/**
* @group legacy
*/
#[Group('legacy')]
public function testAMacroCallWithParenthesesDoesNotSilenceOneWithoutOnTheSameLine(): void
{
$twig = new Environment(new ArrayLoader());
$source = new Source('{% import _self as macros %}{{ macros.foo() }}{{ macros.foo }}', 'index');
$deprecations = $this->collectDeprecations(static function () use ($twig, $source) {
$twig->parse($twig->tokenize($source));
});
$this->assertSame([
'Since twig/twig 3.29: Omitting parentheses when calling the macro "foo" is deprecated and will throw a SyntaxError in Twig 4.0; add parentheses after the macro name in "index" at line 1.',
], $deprecations);
}
/**
* @dataProvider provideMacroTargetExpressionsWithoutParentheses
*/
#[DataProvider('provideMacroTargetExpressionsWithoutParentheses')]
public function testMacroTargetsWithoutParenthesesAreAllowedInDefinedTest(string $expression): void
public function testMacroTargetsWithoutParenthesesAreAllowedInDefinedTest(string $expression, ?string $macroName): void
{
$twig = new Environment(new ArrayLoader());
@@ -533,6 +637,31 @@ EOF, 'index')));
}
}
/**
* @return list<string>
*/
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;
}
protected function getParser()
{
$parser = new Parser(new Environment(new ArrayLoader()));