From 88b60b0f915745394ae96f0b9655774a2c622aa3 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Thu, 23 Jul 2026 14:54:47 +0200 Subject: [PATCH] [Symfony73] Keep AbstractExtension when Twig extension overrides a built-in function/filter --- .../keep_builtin_convert_custom.php.inc | 61 +++++++++++ .../skip_builtin_function_override.php.inc | 26 +++++ .../GetMethodToAsTwigAttributeTransformer.php | 103 +++++++++++++++++- 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 rules-tests/Symfony73/Rector/Class_/GetFunctionsToAsTwigFunctionAttributeRector/Fixture/keep_builtin_convert_custom.php.inc create mode 100644 rules-tests/Symfony73/Rector/Class_/GetFunctionsToAsTwigFunctionAttributeRector/Fixture/skip_builtin_function_override.php.inc diff --git a/rules-tests/Symfony73/Rector/Class_/GetFunctionsToAsTwigFunctionAttributeRector/Fixture/keep_builtin_convert_custom.php.inc b/rules-tests/Symfony73/Rector/Class_/GetFunctionsToAsTwigFunctionAttributeRector/Fixture/keep_builtin_convert_custom.php.inc new file mode 100644 index 00000000..f9e2ebfa --- /dev/null +++ b/rules-tests/Symfony73/Rector/Class_/GetFunctionsToAsTwigFunctionAttributeRector/Fixture/keep_builtin_convert_custom.php.inc @@ -0,0 +1,61 @@ +includeWithEvent(...), ['needs_environment' => true]), + new TwigFunction('custom_function', [$this, 'customFunction']), + ]; + } + + public function includeWithEvent(Environment $env, $template) + { + return $template; + } + + public function customFunction($value) + { + return $value; + } +} + +?> +----- +includeWithEvent(...), ['needs_environment' => true]), + ]; + } + + public function includeWithEvent(Environment $env, $template) + { + return $template; + } + + #[\Twig\Attribute\AsTwigFunction(name: 'custom_function')] + public function customFunction($value) + { + return $value; + } +} + +?> diff --git a/rules-tests/Symfony73/Rector/Class_/GetFunctionsToAsTwigFunctionAttributeRector/Fixture/skip_builtin_function_override.php.inc b/rules-tests/Symfony73/Rector/Class_/GetFunctionsToAsTwigFunctionAttributeRector/Fixture/skip_builtin_function_override.php.inc new file mode 100644 index 00000000..c44ce76a --- /dev/null +++ b/rules-tests/Symfony73/Rector/Class_/GetFunctionsToAsTwigFunctionAttributeRector/Fixture/skip_builtin_function_override.php.inc @@ -0,0 +1,26 @@ +includeWithEvent(...), [ + 'needs_environment' => true, + 'needs_context' => true, + 'is_safe' => ['html'], + ]), + ]; + } + + public function includeWithEvent(\Twig\Environment $env, array $context, $template) + { + return $template; + } +} diff --git a/rules/Symfony73/GetMethodToAsTwigAttributeTransformer.php b/rules/Symfony73/GetMethodToAsTwigAttributeTransformer.php index ec69be3c..dfeaf003 100644 --- a/rules/Symfony73/GetMethodToAsTwigAttributeTransformer.php +++ b/rules/Symfony73/GetMethodToAsTwigAttributeTransformer.php @@ -57,6 +57,75 @@ 'getGlobals', ]; + /** + * Built-in Twig function names. Overriding one only works while the class stays a classic + * "extends AbstractExtension", so such an item must remain in the array and not become an attribute. + * + * @var string[] + */ + private const array CORE_TWIG_FUNCTION_NAMES = [ + 'attribute', + 'block', + 'constant', + 'cycle', + 'date', + 'dump', + 'enum_cases', + 'include', + 'max', + 'min', + 'parent', + 'random', + 'range', + 'source', + 'template_from_string', + ]; + + /** + * Built-in Twig filter names, see self::CORE_TWIG_FUNCTION_NAMES for the reasoning. + * + * @var string[] + */ + private const array CORE_TWIG_FILTER_NAMES = [ + 'abs', + 'batch', + 'capitalize', + 'column', + 'convert_encoding', + 'date', + 'date_modify', + 'default', + 'e', + 'escape', + 'filter', + 'first', + 'format', + 'join', + 'json_encode', + 'keys', + 'last', + 'length', + 'lower', + 'map', + 'merge', + 'nl2br', + 'number_format', + 'raw', + 'reduce', + 'replace', + 'reverse', + 'round', + 'slice', + 'sort', + 'spaceless', + 'split', + 'striptags', + 'title', + 'trim', + 'upper', + 'url_encode', + ]; + public function __construct( private LocalArrayMethodCallableMatcher $localArrayMethodCallableMatcher, private ReturnEmptyArrayMethodRemover $returnEmptyArrayMethodRemover, @@ -104,6 +173,12 @@ public function transformClassGetMethodToAttributeMarker( return false; } + // items that override a built-in Twig function/filter must stay registered the classic way, + // so keep them in the array and let the class keep "extends AbstractExtension" + if ($this->isBuiltinTwigNameOverride($arrayItem, $methodName)) { + continue; + } + $conversion = $this->matchArrayItemConversion( $key, $arrayItem, @@ -118,6 +193,11 @@ public function transformClassGetMethodToAttributeMarker( $conversions[] = $conversion; } + // only built-in overrides (or none) left to convert, nothing to do + if ($conversions === []) { + return false; + } + // attribute-based extensions and "extends AbstractExtension" are incompatible, so the class // must not keep relying on the parent class for other registrations (e.g. getTests(), globals) if ($this->stillRequiresAbstractExtension($class, $methodName)) { @@ -141,7 +221,10 @@ public function transformClassGetMethodToAttributeMarker( $this->returnEmptyArrayMethodRemover->removeClassMethodIfArrayEmpty($class, $returnArray, $methodName); - if ($class->extends instanceof FullyQualified && $class->extends->toString() === TwigClass::TWIG_EXTENSION) { + // a kept built-in override leaves the array non-empty, so the class still needs the parent extension + if ($returnArray->items === [] + && $class->extends instanceof FullyQualified + && $class->extends->toString() === TwigClass::TWIG_EXTENSION) { $class->extends = null; } @@ -227,6 +310,24 @@ private function matchArrayItemConversion( return new AsTwigAttributeConversion($key, $localMethod, $nameArg, $optionArguments); } + private function isBuiltinTwigNameOverride(ArrayItem $arrayItem, string $methodName): bool + { + if (! $arrayItem->value instanceof New_) { + return false; + } + + $firstArg = $arrayItem->value->getArgs()[0] ?? null; + if (! $firstArg?->value instanceof String_) { + return false; + } + + $builtinNames = $methodName === 'getFilters' + ? self::CORE_TWIG_FILTER_NAMES + : self::CORE_TWIG_FUNCTION_NAMES; + + return in_array($firstArg->value->value, $builtinNames, true); + } + private function stillRequiresAbstractExtension(Class_ $class, string $convertedMethodName): bool { foreach ($class->getMethods() as $classMethod) {