From c8780d134b86b0c4e2fb8cd58c9145a3173aff10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 27 Nov 2024 02:22:17 +0100 Subject: [PATCH] [TwigBundle] Enable `#[AsTwigFilter]`, `#[AsTwigFunction]` and `#[AsTwigTest]` attributes to configure runtime extensions --- src/Symfony/Bundle/TwigBundle/CHANGELOG.md | 6 + .../Compiler/AttributeExtensionPass.php | 52 ++++++++ .../DependencyInjection/TwigExtension.php | 8 ++ .../Functional/AttributeExtensionTest.php | 120 ++++++++++++++++++ src/Symfony/Bundle/TwigBundle/TwigBundle.php | 2 + 5 files changed, 188 insertions(+) create mode 100644 src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/AttributeExtensionPass.php create mode 100644 src/Symfony/Bundle/TwigBundle/Tests/Functional/AttributeExtensionTest.php diff --git a/src/Symfony/Bundle/TwigBundle/CHANGELOG.md b/src/Symfony/Bundle/TwigBundle/CHANGELOG.md index 91722564d0bd1..c95e9f04a0a0d 100644 --- a/src/Symfony/Bundle/TwigBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/TwigBundle/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +7.3 +--- + + * Enable `#[AsTwigFilter]`, `#[AsTwigFunction]` and `#[AsTwigTest]` attributes + to configure extensions on runtime classes + 7.1 --- diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/AttributeExtensionPass.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/AttributeExtensionPass.php new file mode 100644 index 0000000000000..24f760802bc94 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/AttributeExtensionPass.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\TwigBundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Twig\Attribute\AsTwigFilter; +use Twig\Attribute\AsTwigFunction; +use Twig\Attribute\AsTwigTest; +use Twig\Extension\AttributeExtension; + +/** + * Register an instance of AttributeExtension for each service using the + * PHP attributes to declare Twig callables. + * + * @author Jérôme Tamarelle + * + * @internal + */ +final class AttributeExtensionPass implements CompilerPassInterface +{ + private const TAG = 'twig.attribute_extension'; + + public static function autoconfigureFromAttribute(ChildDefinition $definition, AsTwigFilter|AsTwigFunction|AsTwigTest $attribute, \ReflectionMethod $reflector): void + { + $definition->addTag(self::TAG); + + // The service must be tagged as a runtime to call non-static methods + if (!$reflector->isStatic()) { + $definition->addTag('twig.runtime'); + } + } + + public function process(ContainerBuilder $container): void + { + foreach ($container->findTaggedServiceIds(self::TAG, true) as $id => $tags) { + $container->register('.twig.extension.'.$id, AttributeExtension::class) + ->setArguments([$container->getDefinition($id)->getClass()]) + ->addTag('twig.extension'); + } + } +} diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php index a3563b2d0d0ec..b676e10a81ad1 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\TwigBundle\DependencyInjection; +use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\AttributeExtensionPass; use Symfony\Component\AssetMapper\AssetMapper; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Resource\FileExistenceResource; @@ -25,6 +26,9 @@ use Symfony\Component\Translation\LocaleSwitcher; use Symfony\Component\Translation\Translator; use Symfony\Contracts\Service\ResetInterface; +use Twig\Attribute\AsTwigFilter; +use Twig\Attribute\AsTwigFunction; +use Twig\Attribute\AsTwigTest; use Twig\Environment; use Twig\Extension\ExtensionInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -179,6 +183,10 @@ public function load(array $configs, ContainerBuilder $container): void $container->registerForAutoconfiguration(LoaderInterface::class)->addTag('twig.loader'); $container->registerForAutoconfiguration(RuntimeExtensionInterface::class)->addTag('twig.runtime'); + $container->registerAttributeForAutoconfiguration(AsTwigFilter::class, AttributeExtensionPass::autoconfigureFromAttribute(...)); + $container->registerAttributeForAutoconfiguration(AsTwigFunction::class, AttributeExtensionPass::autoconfigureFromAttribute(...)); + $container->registerAttributeForAutoconfiguration(AsTwigTest::class, AttributeExtensionPass::autoconfigureFromAttribute(...)); + if (false === $config['cache']) { $container->removeDefinition('twig.template_cache_warmer'); } diff --git a/src/Symfony/Bundle/TwigBundle/Tests/Functional/AttributeExtensionTest.php b/src/Symfony/Bundle/TwigBundle/Tests/Functional/AttributeExtensionTest.php new file mode 100644 index 0000000000000..e9bd8e2e93a90 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Tests/Functional/AttributeExtensionTest.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\TwigBundle\Tests\Functional; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\TwigBundle\Tests\TestCase; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpKernel\Kernel; +use Twig\Attribute\AsTwigFilter; +use Twig\Attribute\AsTwigFunction; +use Twig\Attribute\AsTwigTest; +use Twig\Environment; +use Twig\Error\RuntimeError; +use Twig\Extension\AttributeExtension; + +class AttributeExtensionTest extends TestCase +{ + public function testExtensionWithAttributes() + { + if (!class_exists(AttributeExtension::class)) { + self::markTestSkipped('Twig 3.21 is required.'); + } + + $kernel = new class('test', true) extends Kernel + { + public function registerBundles(): iterable + { + return [new FrameworkBundle(), new TwigBundle()]; + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(static function (ContainerBuilder $container) { + $container->register(StaticExtensionWithAttributes::class, StaticExtensionWithAttributes::class) + ->setAutoconfigured(true); + $container->register(RuntimeExtensionWithAttributes::class, RuntimeExtensionWithAttributes::class) + ->setArguments(['prefix_']) + ->setAutoconfigured(true); + + $container->setAlias('twig_test', 'twig')->setPublic(true); + }); + } + + public function getProjectDir(): string + { + return sys_get_temp_dir().'/'.Kernel::VERSION.'/AttributeExtension'; + } + }; + + $kernel->boot(); + + /** @var Environment $twig */ + $twig = $kernel->getContainer()->get('twig_test'); + + self::assertInstanceOf(AttributeExtension::class, $twig->getExtension(StaticExtensionWithAttributes::class)); + self::assertInstanceOf(AttributeExtension::class, $twig->getExtension(RuntimeExtensionWithAttributes::class)); + self::assertInstanceOf(RuntimeExtensionWithAttributes::class, $twig->getRuntime(RuntimeExtensionWithAttributes::class)); + + self::expectException(RuntimeError::class); + $twig->getRuntime(StaticExtensionWithAttributes::class); + } + + /** + * @before + * @after + */ + protected function deleteTempDir() + { + if (file_exists($dir = sys_get_temp_dir().'/'.Kernel::VERSION.'/AttributeExtension')) { + (new Filesystem())->remove($dir); + } + } +} + +class StaticExtensionWithAttributes +{ + #[AsTwigFilter('foo')] + public static function fooFilter(string $value): string + { + return $value; + } + + #[AsTwigFunction('foo')] + public static function fooFunction(string $value): string + { + return $value; + } + + #[AsTwigTest('foo')] + public static function fooTest(bool $value): bool + { + return $value; + } +} + +class RuntimeExtensionWithAttributes +{ + public function __construct(private bool $prefix) + { + } + + #[AsTwigFilter('foo')] + #[AsTwigFunction('foo')] + public function prefix(string $value): string + { + return $this->prefix.$value; + } +} diff --git a/src/Symfony/Bundle/TwigBundle/TwigBundle.php b/src/Symfony/Bundle/TwigBundle/TwigBundle.php index 5ff13b1bc8687..d9bc6078689b3 100644 --- a/src/Symfony/Bundle/TwigBundle/TwigBundle.php +++ b/src/Symfony/Bundle/TwigBundle/TwigBundle.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\TwigBundle; +use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\AttributeExtensionPass; use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\ExtensionPass; use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\RuntimeLoaderPass; use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\TwigEnvironmentPass; @@ -33,6 +34,7 @@ public function build(ContainerBuilder $container): void // ExtensionPass must be run before the FragmentRendererPass as it adds tags that are processed later $container->addCompilerPass(new ExtensionPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10); + $container->addCompilerPass(new AttributeExtensionPass()); $container->addCompilerPass(new TwigEnvironmentPass()); $container->addCompilerPass(new TwigLoaderPass()); $container->addCompilerPass(new RuntimeLoaderPass(), PassConfig::TYPE_BEFORE_REMOVING);