diff --git a/CHANGELOG.md b/CHANGELOG.md index 91722564..40d5be35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ CHANGELOG ========= +7.3 +--- + + * Enable `#[AsTwigFilter]`, `#[AsTwigFunction]` and `#[AsTwigTest]` attributes + to configure extensions on runtime classes + * Add support for a `twig` validator + * Use `ChainCache` to store warmed-up cache in `kernel.build_dir` and runtime cache in `kernel.cache_dir` + * Make `TemplateCacheWarmer` use `kernel.build_dir` instead of `kernel.cache_dir` + 7.1 --- diff --git a/CacheWarmer/TemplateCacheWarmer.php b/CacheWarmer/TemplateCacheWarmer.php index 69b0b2ce..3bb89760 100644 --- a/CacheWarmer/TemplateCacheWarmer.php +++ b/CacheWarmer/TemplateCacheWarmer.php @@ -14,6 +14,8 @@ use Psr\Container\ContainerInterface; use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; +use Twig\Cache\CacheInterface; +use Twig\Cache\NullCache; use Twig\Environment; use Twig\Error\Error; @@ -26,34 +28,56 @@ */ class TemplateCacheWarmer implements CacheWarmerInterface, ServiceSubscriberInterface { - private ContainerInterface $container; private Environment $twig; - private iterable $iterator; - public function __construct(ContainerInterface $container, iterable $iterator) - { - // As this cache warmer is optional, dependencies should be lazy-loaded, that's why a container should be injected. - $this->container = $container; - $this->iterator = $iterator; + /** + * As this cache warmer is optional, dependencies should be lazy-loaded, that's why a container should be injected. + */ + public function __construct( + private ContainerInterface $container, + private iterable $iterator, + private ?CacheInterface $cache = null, + ) { } public function warmUp(string $cacheDir, ?string $buildDir = null): array { $this->twig ??= $this->container->get('twig'); - foreach ($this->iterator as $template) { - try { - $this->twig->load($template); - } catch (Error) { + $originalCache = $this->twig->getCache(); + if ($originalCache instanceof NullCache) { + // There's no point to warm up a cache that won't be used afterward + return []; + } + + if (null !== $this->cache) { + if (!$buildDir) { /* - * Problem during compilation, give up for this template (e.g. syntax errors). - * Failing silently here allows to ignore templates that rely on functions that aren't available in - * the current environment. For example, the WebProfilerBundle shouldn't be available in the prod - * environment, but some templates that are never used in prod might rely on functions the bundle provides. - * As we can't detect which templates are "really" important, we try to load all of them and ignore - * errors. Error checks may be performed by calling the lint:twig command. + * The cache has already been warmup during the build of the container, when $buildDir was set. */ + return []; + } + // Swap the cache for the warmup as the Twig Environment has the ChainCache injected + $this->twig->setCache($this->cache); + } + + try { + foreach ($this->iterator as $template) { + try { + $this->twig->load($template); + } catch (Error) { + /* + * Problem during compilation, give up for this template (e.g. syntax errors). + * Failing silently here allows to ignore templates that rely on functions that aren't available in + * the current environment. For example, the WebProfilerBundle shouldn't be available in the prod + * environment, but some templates that are never used in prod might rely on functions the bundle provides. + * As we can't detect which templates are "really" important, we try to load all of them and ignore + * errors. Error checks may be performed by calling the lint:twig command. + */ + } } + } finally { + $this->twig->setCache($originalCache); } return []; diff --git a/DependencyInjection/Compiler/AttributeExtensionPass.php b/DependencyInjection/Compiler/AttributeExtensionPass.php new file mode 100644 index 00000000..35487486 --- /dev/null +++ b/DependencyInjection/Compiler/AttributeExtensionPass.php @@ -0,0 +1,63 @@ + + * + * 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 Symfony\Component\DependencyInjection\Exception\LogicException; +use Twig\Attribute\AsTwigFilter; +use Twig\Attribute\AsTwigFunction; +use Twig\Attribute\AsTwigTest; +use Twig\Extension\AbstractExtension; +use Twig\Extension\AttributeExtension; +use Twig\Extension\ExtensionInterface; + +/** + * 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 + { + $class = $reflector->getDeclaringClass(); + if ($class->implementsInterface(ExtensionInterface::class)) { + if ($class->isSubclassOf(AbstractExtension::class)) { + throw new LogicException(\sprintf('The class "%s" cannot extend "%s" and use the "#[%s]" attribute on method "%s()", choose one or the other.', $class->name, AbstractExtension::class, $attribute::class, $reflector->name)); + } + throw new LogicException(\sprintf('The class "%s" cannot implement "%s" and use the "#[%s]" attribute on method "%s()", choose one or the other.', $class->name, ExtensionInterface::class, $attribute::class, $reflector->name)); + } + + $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/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index ca23a0df..354e1a4e 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -32,7 +32,9 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder = new TreeBuilder('twig'); $rootNode = $treeBuilder->getRootNode(); - $rootNode->beforeNormalization() + $rootNode + ->docUrl('https://symfony.com/doc/{version:major}.{version:minor}/reference/configuration/twig.html', 'symfony/twig-bundle') + ->beforeNormalization() ->ifTrue(fn ($v) => \is_array($v) && \array_key_exists('exception_controller', $v)) ->then(function ($v) { if (isset($v['exception_controller'])) { @@ -134,19 +136,19 @@ private function addTwigOptions(ArrayNodeDefinition $rootNode): void ->example('Twig\Template') ->cannotBeEmpty() ->end() - ->scalarNode('cache')->defaultValue('%kernel.cache_dir%/twig')->end() + ->scalarNode('cache')->defaultTrue()->end() ->scalarNode('charset')->defaultValue('%kernel.charset%')->end() ->booleanNode('debug')->defaultValue('%kernel.debug%')->end() ->booleanNode('strict_variables')->defaultValue('%kernel.debug%')->end() ->scalarNode('auto_reload')->end() ->integerNode('optimizations')->min(-1)->end() ->scalarNode('default_path') - ->info('The default path used to load templates') + ->info('The default path used to load templates.') ->defaultValue('%kernel.project_dir%/templates') ->end() ->arrayNode('file_name_pattern') ->example('*.twig') - ->info('Pattern of file name used for cache warmer and linter') + ->info('Pattern of file name used for cache warmer and linter.') ->beforeNormalization() ->ifString() ->then(fn ($value) => [$value]) @@ -190,19 +192,19 @@ private function addTwigFormatOptions(ArrayNodeDefinition $rootNode): void $rootNode ->children() ->arrayNode('date') - ->info('The default format options used by the date filter') + ->info('The default format options used by the date filter.') ->addDefaultsIfNotSet() ->children() ->scalarNode('format')->defaultValue('F j, Y H:i')->end() ->scalarNode('interval_format')->defaultValue('%d days')->end() ->scalarNode('timezone') - ->info('The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used') + ->info('The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used.') ->defaultNull() ->end() ->end() ->end() ->arrayNode('number_format') - ->info('The default format options for the number_format filter') + ->info('The default format options for the number_format filter.') ->addDefaultsIfNotSet() ->children() ->integerNode('decimals')->defaultValue(0)->end() @@ -221,7 +223,7 @@ private function addMailerSection(ArrayNodeDefinition $rootNode): void ->arrayNode('mailer') ->children() ->scalarNode('html_to_text_converter') - ->info(sprintf('A service implementing the "%s"', HtmlToTextConverterInterface::class)) + ->info(\sprintf('A service implementing the "%s".', HtmlToTextConverterInterface::class)) ->defaultNull() ->end() ->end() diff --git a/DependencyInjection/Configurator/EnvironmentConfigurator.php b/DependencyInjection/Configurator/EnvironmentConfigurator.php index 64d76c00..35f7b890 100644 --- a/DependencyInjection/Configurator/EnvironmentConfigurator.php +++ b/DependencyInjection/Configurator/EnvironmentConfigurator.php @@ -22,21 +22,14 @@ */ class EnvironmentConfigurator { - private string $dateFormat; - private string $intervalFormat; - private ?string $timezone; - private int $decimals; - private string $decimalPoint; - private string $thousandsSeparator; - - public function __construct(string $dateFormat, string $intervalFormat, ?string $timezone, int $decimals, string $decimalPoint, string $thousandsSeparator) - { - $this->dateFormat = $dateFormat; - $this->intervalFormat = $intervalFormat; - $this->timezone = $timezone; - $this->decimals = $decimals; - $this->decimalPoint = $decimalPoint; - $this->thousandsSeparator = $thousandsSeparator; + public function __construct( + private string $dateFormat, + private string $intervalFormat, + private ?string $timezone, + private int $decimals, + private string $decimalPoint, + private string $thousandsSeparator, + ) { } public function configure(Environment $environment): void diff --git a/DependencyInjection/TwigExtension.php b/DependencyInjection/TwigExtension.php index a3563b2d..41817295 100644 --- a/DependencyInjection/TwigExtension.php +++ b/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; @@ -24,7 +25,12 @@ use Symfony\Component\Mailer\Mailer; use Symfony\Component\Translation\LocaleSwitcher; use Symfony\Component\Translation\Translator; +use Symfony\Component\Validator\Constraint; use Symfony\Contracts\Service\ResetInterface; +use Twig\Attribute\AsTwigFilter; +use Twig\Attribute\AsTwigFunction; +use Twig\Attribute\AsTwigTest; +use Twig\Cache\FilesystemCache; use Twig\Environment; use Twig\Extension\ExtensionInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -65,6 +71,10 @@ public function load(array $configs, ContainerBuilder $container): void $container->removeDefinition('twig.translation.extractor'); } + if ($container::willBeAvailable('symfony/validator', Constraint::class, ['symfony/twig-bundle'])) { + $loader->load('validator.php'); + } + foreach ($configs as $key => $config) { if (isset($config['globals'])) { foreach ($config['globals'] as $name => $value) { @@ -158,6 +168,31 @@ public function load(array $configs, ContainerBuilder $container): void } } + if (true === $config['cache']) { + $autoReloadOrDefault = $container->getParameterBag()->resolveValue($config['auto_reload'] ?? $config['debug']); + $buildDir = $container->getParameter('kernel.build_dir'); + $cacheDir = $container->getParameter('kernel.cache_dir'); + + if ($autoReloadOrDefault || $cacheDir === $buildDir) { + $config['cache'] = '%kernel.cache_dir%/twig'; + } + } + + if (true === $config['cache']) { + $config['cache'] = new Reference('twig.template_cache.chain'); + } else { + $container->removeDefinition('twig.template_cache.chain'); + $container->removeDefinition('twig.template_cache.runtime_cache'); + $container->removeDefinition('twig.template_cache.readonly_cache'); + $container->removeDefinition('twig.template_cache.warmup_cache'); + + if (false === $config['cache']) { + $container->removeDefinition('twig.template_cache_warmer'); + } else { + $container->getDefinition('twig.template_cache_warmer')->replaceArgument(2, null); + } + } + if (isset($config['autoescape_service'])) { $config['autoescape'] = [new Reference($config['autoescape_service']), $config['autoescape_service_method'] ?? '__invoke']; } else { @@ -179,9 +214,9 @@ public function load(array $configs, ContainerBuilder $container): void $container->registerForAutoconfiguration(LoaderInterface::class)->addTag('twig.loader'); $container->registerForAutoconfiguration(RuntimeExtensionInterface::class)->addTag('twig.runtime'); - if (false === $config['cache']) { - $container->removeDefinition('twig.template_cache_warmer'); - } + $container->registerAttributeForAutoconfiguration(AsTwigFilter::class, AttributeExtensionPass::autoconfigureFromAttribute(...)); + $container->registerAttributeForAutoconfiguration(AsTwigFunction::class, AttributeExtensionPass::autoconfigureFromAttribute(...)); + $container->registerAttributeForAutoconfiguration(AsTwigTest::class, AttributeExtensionPass::autoconfigureFromAttribute(...)); } private function getBundleTemplatePaths(ContainerBuilder $container, array $config): array diff --git a/Resources/config/twig.php b/Resources/config/twig.php index 02631d28..812ac1f6 100644 --- a/Resources/config/twig.php +++ b/Resources/config/twig.php @@ -36,7 +36,9 @@ use Symfony\Bundle\TwigBundle\CacheWarmer\TemplateCacheWarmer; use Symfony\Bundle\TwigBundle\DependencyInjection\Configurator\EnvironmentConfigurator; use Symfony\Bundle\TwigBundle\TemplateIterator; +use Twig\Cache\ChainCache; use Twig\Cache\FilesystemCache; +use Twig\Cache\ReadOnlyFilesystemCache; use Twig\Environment; use Twig\Extension\CoreExtension; use Twig\Extension\DebugExtension; @@ -79,8 +81,24 @@ ->set('twig.template_iterator', TemplateIterator::class) ->args([service('kernel'), abstract_arg('Twig paths'), param('twig.default_path'), abstract_arg('File name pattern')]) + ->set('twig.template_cache.runtime_cache', FilesystemCache::class) + ->args([param('kernel.cache_dir').'/twig']) + + ->set('twig.template_cache.readonly_cache', ReadOnlyFilesystemCache::class) + ->args([param('kernel.build_dir').'/twig']) + + ->set('twig.template_cache.warmup_cache', FilesystemCache::class) + ->args([param('kernel.build_dir').'/twig']) + + ->set('twig.template_cache.chain', ChainCache::class) + ->args([[service('twig.template_cache.readonly_cache'), service('twig.template_cache.runtime_cache')]]) + ->set('twig.template_cache_warmer', TemplateCacheWarmer::class) - ->args([service(ContainerInterface::class), service('twig.template_iterator')]) + ->args([ + service(ContainerInterface::class), + service('twig.template_iterator'), + service('twig.template_cache.warmup_cache'), + ]) ->tag('kernel.cache_warmer') ->tag('container.service_subscriber', ['id' => 'twig']) diff --git a/Resources/config/validator.php b/Resources/config/validator.php new file mode 100644 index 00000000..1c0e8dd4 --- /dev/null +++ b/Resources/config/validator.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Bridge\Twig\Validator\Constraints\TwigValidator; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('twig.validator', TwigValidator::class) + ->args([service('twig')]) + ->tag('validator.constraint_validator') + ; +}; diff --git a/TemplateIterator.php b/TemplateIterator.php index bd42f1ac..04cb2a5a 100644 --- a/TemplateIterator.php +++ b/TemplateIterator.php @@ -25,23 +25,19 @@ */ class TemplateIterator implements \IteratorAggregate { - private KernelInterface $kernel; private \Traversable $templates; - private array $paths; - private ?string $defaultPath; - private array $namePatterns; /** * @param array $paths Additional Twig paths to warm * @param string|null $defaultPath The directory where global templates can be stored * @param string[] $namePatterns Pattern of file names */ - public function __construct(KernelInterface $kernel, array $paths = [], ?string $defaultPath = null, array $namePatterns = []) - { - $this->kernel = $kernel; - $this->paths = $paths; - $this->defaultPath = $defaultPath; - $this->namePatterns = $namePatterns; + public function __construct( + private KernelInterface $kernel, + private array $paths = [], + private ?string $defaultPath = null, + private array $namePatterns = [], + ) { } public function getIterator(): \Traversable diff --git a/Tests/DependencyInjection/Fixtures/php/full.php b/Tests/DependencyInjection/Fixtures/php/full.php index f87af5a1..68c7f5a3 100644 --- a/Tests/DependencyInjection/Fixtures/php/full.php +++ b/Tests/DependencyInjection/Fixtures/php/full.php @@ -10,8 +10,7 @@ 'pi' => 3.14, 'bad' => ['key' => 'foo'], ], - 'auto_reload' => true, - 'cache' => '/tmp', + 'auto_reload' => false, 'charset' => 'ISO-8859-1', 'debug' => true, 'strict_variables' => true, diff --git a/Tests/DependencyInjection/Fixtures/php/no-cache.php b/Tests/DependencyInjection/Fixtures/php/no-cache.php new file mode 100644 index 00000000..df1ae5c6 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/php/no-cache.php @@ -0,0 +1,5 @@ +loadFromExtension('twig', [ + 'cache' => false, +]); diff --git a/Tests/DependencyInjection/Fixtures/php/path-cache.php b/Tests/DependencyInjection/Fixtures/php/path-cache.php new file mode 100644 index 00000000..f0701a57 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/php/path-cache.php @@ -0,0 +1,5 @@ +loadFromExtension('twig', [ + 'cache' => 'random-path', +]); diff --git a/Tests/DependencyInjection/Fixtures/php/prod-cache.php b/Tests/DependencyInjection/Fixtures/php/prod-cache.php new file mode 100644 index 00000000..62885460 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/php/prod-cache.php @@ -0,0 +1,6 @@ +loadFromExtension('twig', [ + 'cache' => true, + 'auto_reload' => false, +]); diff --git a/Tests/DependencyInjection/Fixtures/xml/extra.xml b/Tests/DependencyInjection/Fixtures/xml/extra.xml index f1cf8985..df02c9dc 100644 --- a/Tests/DependencyInjection/Fixtures/xml/extra.xml +++ b/Tests/DependencyInjection/Fixtures/xml/extra.xml @@ -6,7 +6,7 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd http://symfony.com/schema/dic/twig https://symfony.com/schema/dic/twig/twig-1.0.xsd"> - + namespaced_path3 diff --git a/Tests/DependencyInjection/Fixtures/xml/full.xml b/Tests/DependencyInjection/Fixtures/xml/full.xml index 528a466b..3349e0d5 100644 --- a/Tests/DependencyInjection/Fixtures/xml/full.xml +++ b/Tests/DependencyInjection/Fixtures/xml/full.xml @@ -6,7 +6,7 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd http://symfony.com/schema/dic/twig https://symfony.com/schema/dic/twig/twig-1.0.xsd"> - + MyBundle::form.html.twig @@qux diff --git a/Tests/DependencyInjection/Fixtures/xml/no-cache.xml b/Tests/DependencyInjection/Fixtures/xml/no-cache.xml new file mode 100644 index 00000000..f6fa72c7 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/xml/no-cache.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/Tests/DependencyInjection/Fixtures/xml/path-cache.xml b/Tests/DependencyInjection/Fixtures/xml/path-cache.xml new file mode 100644 index 00000000..9caf2fc0 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/xml/path-cache.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/Tests/DependencyInjection/Fixtures/xml/prod-cache.xml b/Tests/DependencyInjection/Fixtures/xml/prod-cache.xml new file mode 100644 index 00000000..6ee9f385 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/xml/prod-cache.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/Tests/DependencyInjection/Fixtures/yml/full.yml b/Tests/DependencyInjection/Fixtures/yml/full.yml index 6c249d37..ab19cbf0 100644 --- a/Tests/DependencyInjection/Fixtures/yml/full.yml +++ b/Tests/DependencyInjection/Fixtures/yml/full.yml @@ -6,8 +6,7 @@ twig: baz: "@@qux" pi: 3.14 bad: {key: foo} - auto_reload: true - cache: /tmp + auto_reload: false charset: ISO-8859-1 debug: true strict_variables: true diff --git a/Tests/DependencyInjection/Fixtures/yml/no-cache.yml b/Tests/DependencyInjection/Fixtures/yml/no-cache.yml new file mode 100644 index 00000000..c1e9f184 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/yml/no-cache.yml @@ -0,0 +1,2 @@ +twig: + cache: false diff --git a/Tests/DependencyInjection/Fixtures/yml/path-cache.yml b/Tests/DependencyInjection/Fixtures/yml/path-cache.yml new file mode 100644 index 00000000..04e9d1dc --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/yml/path-cache.yml @@ -0,0 +1,2 @@ +twig: + cache: random-path diff --git a/Tests/DependencyInjection/Fixtures/yml/prod-cache.yml b/Tests/DependencyInjection/Fixtures/yml/prod-cache.yml new file mode 100644 index 00000000..82a1dd9e --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/yml/prod-cache.yml @@ -0,0 +1,3 @@ +twig: + cache: true + auto_reload: false diff --git a/Tests/DependencyInjection/TwigExtensionTest.php b/Tests/DependencyInjection/TwigExtensionTest.php index bc7013c3..086a4cdd 100644 --- a/Tests/DependencyInjection/TwigExtensionTest.php +++ b/Tests/DependencyInjection/TwigExtensionTest.php @@ -11,7 +11,7 @@ namespace Symfony\Bundle\TwigBundle\Tests\DependencyInjection; -use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait; use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\RuntimeLoaderPass; use Symfony\Bundle\TwigBundle\DependencyInjection\TwigExtension; use Symfony\Bundle\TwigBundle\Tests\DependencyInjection\AcmeBundle\AcmeBundle; @@ -28,11 +28,12 @@ use Symfony\Component\Form\FormRenderer; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\Validator\Validator\ValidatorInterface; use Twig\Environment; class TwigExtensionTest extends TestCase { - use ExpectDeprecationTrait; + use ExpectUserDeprecationMessageTrait; public function testLoadEmptyConfiguration() { @@ -54,14 +55,20 @@ public function testLoadEmptyConfiguration() if (class_exists(Mailer::class)) { $this->assertCount(2, $container->getDefinition('twig.mime_body_renderer')->getArguments()); } + + if (interface_exists(ValidatorInterface::class)) { + $this->assertTrue($container->hasDefinition('twig.validator')); + } else { + $this->assertFalse($container->hasDefinition('twig.validator')); + } } /** - * @dataProvider getFormats + * @dataProvider getFormatsAndBuildDir */ - public function testLoadFullConfiguration(string $format) + public function testLoadFullConfiguration(string $format, ?string $buildDir) { - $container = $this->createContainer(); + $container = $this->createContainer($buildDir); $container->registerExtension(new TwigExtension()); $this->loadFromFile($container, 'full', $format); $this->compileContainer($container); @@ -92,13 +99,64 @@ public function testLoadFullConfiguration(string $format) // Twig options $options = $container->getDefinition('twig')->getArgument(1); - $this->assertTrue($options['auto_reload'], '->load() sets the auto_reload option'); + $this->assertFalse($options['auto_reload'], '->load() sets the auto_reload option'); $this->assertSame('name', $options['autoescape'], '->load() sets the autoescape option'); $this->assertArrayNotHasKey('base_template_class', $options, '->load() does not set the base_template_class if none is provided'); - $this->assertEquals('/tmp', $options['cache'], '->load() sets the cache option'); $this->assertEquals('ISO-8859-1', $options['charset'], '->load() sets the charset option'); $this->assertTrue($options['debug'], '->load() sets the debug option'); $this->assertTrue($options['strict_variables'], '->load() sets the strict_variables option'); + $this->assertEquals($buildDir !== null ? new Reference('twig.template_cache.chain') : '%kernel.cache_dir%/twig', $options['cache'], '->load() sets the cache option'); + } + + /** + * @dataProvider getFormatsAndBuildDir + */ + public function testLoadNoCacheConfiguration(string $format, ?string $buildDir) + { + $container = $this->createContainer($buildDir); + $container->registerExtension(new TwigExtension()); + $this->loadFromFile($container, 'no-cache', $format); + $this->compileContainer($container); + + $this->assertEquals(Environment::class, $container->getDefinition('twig')->getClass(), '->load() loads the twig.xml file'); + + // Twig options + $options = $container->getDefinition('twig')->getArgument(1); + $this->assertFalse($options['cache'], '->load() sets cache option to false'); + } + + /** + * @dataProvider getFormatsAndBuildDir + */ + public function testLoadPathCacheConfiguration(string $format, ?string $buildDir) + { + $container = $this->createContainer($buildDir); + $container->registerExtension(new TwigExtension()); + $this->loadFromFile($container, 'path-cache', $format); + $this->compileContainer($container); + + $this->assertEquals(Environment::class, $container->getDefinition('twig')->getClass(), '->load() loads the twig.xml file'); + + // Twig options + $options = $container->getDefinition('twig')->getArgument(1); + $this->assertSame('random-path', $options['cache'], '->load() sets cache option to string path'); + } + + /** + * @dataProvider getFormatsAndBuildDir + */ + public function testLoadProdCacheConfiguration(string $format, ?string $buildDir) + { + $container = $this->createContainer($buildDir); + $container->registerExtension(new TwigExtension()); + $this->loadFromFile($container, 'prod-cache', $format); + $this->compileContainer($container); + + $this->assertEquals(Environment::class, $container->getDefinition('twig')->getClass(), '->load() loads the twig.xml file'); + + // Twig options + $options = $container->getDefinition('twig')->getArgument(1); + $this->assertEquals($buildDir !== null ? new Reference('twig.template_cache.chain') : '%kernel.cache_dir%/twig', $options['cache'], '->load() sets cache option to CacheChain reference'); } /** @@ -111,7 +169,7 @@ public function testLoadCustomBaseTemplateClassConfiguration(string $format) $container = $this->createContainer(); $container->registerExtension(new TwigExtension()); - $this->expectDeprecation('Since symfony/twig-bundle 7.1: The child node "base_template_class" at path "twig" is deprecated.'); + $this->expectUserDeprecationMessage('Since symfony/twig-bundle 7.1: The child node "base_template_class" at path "twig" is deprecated.'); $this->loadFromFile($container, 'templateClass', $format); $this->compileContainer($container); @@ -238,6 +296,19 @@ public static function getFormats(): array ]; } + public static function getFormatsAndBuildDir(): array + { + return [ + ['php', null], + ['php', __DIR__.'/build'], + ['yml', null], + ['yml', __DIR__.'/build'], + ['xml', null], + ['xml', __DIR__.'/build'], + ]; + } + + /** * @dataProvider stopwatchExtensionAvailabilityProvider */ @@ -312,10 +383,11 @@ public function testCustomHtmlToTextConverterService(string $format) $this->assertEquals(new Reference('my_converter'), $bodyRenderer->getArgument('$converter')); } - private function createContainer(): ContainerBuilder + private function createContainer(?string $buildDir = null): ContainerBuilder { $container = new ContainerBuilder(new ParameterBag([ 'kernel.cache_dir' => __DIR__, + 'kernel.build_dir' => $buildDir ?? __DIR__, 'kernel.project_dir' => __DIR__, 'kernel.charset' => 'UTF-8', 'kernel.debug' => false, diff --git a/Tests/Functional/AttributeExtensionTest.php b/Tests/Functional/AttributeExtensionTest.php new file mode 100644 index 00000000..8b4e4555 --- /dev/null +++ b/Tests/Functional/AttributeExtensionTest.php @@ -0,0 +1,167 @@ + + * + * 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 PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\BeforeClass; +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\DependencyInjection\Exception\LogicException; +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\AbstractExtension; +use Twig\Extension\AttributeExtension; + +class AttributeExtensionTest extends TestCase +{ + /** @beforeClass */ + #[BeforeClass] + public static function assertTwigVersion(): void + { + if (!class_exists(AttributeExtension::class)) { + self::markTestSkipped('Twig 3.21 is required.'); + } + } + + public function testExtensionWithAttributes() + { + $kernel = new class extends AttributeExtensionKernel { + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(static function (ContainerBuilder $container) { + $container->setParameter('kernel.secret', 'secret'); + $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); + }); + } + }; + + $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); + } + + public function testInvalidExtensionClass() + { + $kernel = new class extends AttributeExtensionKernel { + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(static function (ContainerBuilder $container) { + $container->register(InvalidExtensionWithAttributes::class, InvalidExtensionWithAttributes::class) + ->setAutoconfigured(true); + }); + } + }; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The class "Symfony\Bundle\TwigBundle\Tests\Functional\InvalidExtensionWithAttributes" cannot extend "Twig\Extension\AbstractExtension" and use the "#[Twig\Attribute\AsTwigFilter]" attribute on method "funFilter()", choose one or the other.'); + + $kernel->boot(); + } + + + /** + * @before + * @after + */ + #[Before, After] + protected function deleteTempDir() + { + if (file_exists($dir = sys_get_temp_dir().'/'.Kernel::VERSION.'/AttributeExtension')) { + (new Filesystem())->remove($dir); + } + } +} + +abstract class AttributeExtensionKernel extends Kernel +{ + public function __construct() + { + parent::__construct('test', true); + } + + public function registerBundles(): iterable + { + return [new FrameworkBundle(), new TwigBundle()]; + } + + public function getProjectDir(): string + { + return sys_get_temp_dir().'/'.Kernel::VERSION.'/AttributeExtension'; + } +} + +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('prefix_foo')] + #[AsTwigFunction('prefix_foo')] + public function prefix(string $value): string + { + return $this->prefix.$value; + } +} + +class InvalidExtensionWithAttributes extends AbstractExtension +{ + #[AsTwigFilter('fun')] + public function funFilter(): string + { + return 'fun'; + } +} diff --git a/TwigBundle.php b/TwigBundle.php index 5ff13b1b..d9bc6078 100644 --- a/TwigBundle.php +++ b/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); diff --git a/composer.json b/composer.json index 88c1dd5b..221a7f47 100644 --- a/composer.json +++ b/composer.json @@ -18,12 +18,12 @@ "require": { "php": ">=8.2", "composer-runtime-api": ">=2.1", - "symfony/config": "^6.4|^7.0", + "symfony/config": "^7.3", "symfony/dependency-injection": "^6.4|^7.0", - "symfony/twig-bridge": "^6.4|^7.0", + "symfony/twig-bridge": "^7.3", "symfony/http-foundation": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", - "twig/twig": "^3.0.4" + "twig/twig": "^3.12" }, "require-dev": { "symfony/asset": "^6.4|^7.0",