diff --git a/CHANGELOG b/CHANGELOG index a93f3a737..49843b72c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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) diff --git a/src/NodeVisitor/CorrectnessNodeVisitor.php b/src/NodeVisitor/CorrectnessNodeVisitor.php index 1cad71cc2..a2b719c62 100644 --- a/src/NodeVisitor/CorrectnessNodeVisitor.php +++ b/src/NodeVisitor/CorrectnessNodeVisitor.php @@ -33,7 +33,10 @@ use Twig\Node\TextNode; final class CorrectnessNodeVisitor implements NodeVisitorInterface { private ?\WeakMap $rootNodes = null; - private ?\WeakMap $checkedMacroReferences = null; + /** + * @var array + */ + 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); } } diff --git a/tests/Fixtures/macros/call_without_parentheses.legacy.test b/tests/Fixtures/macros/call_without_parentheses.legacy.test index cb9b0bef8..04cf38a33 100644 --- a/tests/Fixtures/macros/call_without_parentheses.legacy.test +++ b/tests/Fixtures/macros/call_without_parentheses.legacy.test @@ -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' %} diff --git a/tests/ParserTest.php b/tests/ParserTest.php index e81f01ef3..c38dc98b8 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -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 + */ + 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()));