From d517ef15931d5135d07637d337187b6073b0f986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 5 Apr 2024 16:34:23 +0200 Subject: [PATCH] Make AsTwigExtension extension first party instead of relying on the extension to be registered --- doc/advanced.rst | 35 ++++++---------- src/Environment.php | 7 +++- src/Extension/AttributeExtension.php | 43 +++++++++----------- src/Extension/ModificationAwareInterface.php | 15 ------- src/ExtensionSet.php | 30 ++++++++++---- tests/Extension/AttributeExtensionTest.php | 6 --- 6 files changed, 57 insertions(+), 79 deletions(-) delete mode 100644 src/Extension/ModificationAwareInterface.php diff --git a/doc/advanced.rst b/doc/advanced.rst index 17e2c7fd426..0654a464c9b 100644 --- a/doc/advanced.rst +++ b/doc/advanced.rst @@ -766,8 +766,7 @@ Using PHP Attributes to define extensions From PHP 8.0, you can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and ``#[AsTwigTest]`` on any method of any class to define filters, functions, and tests. -Create a class, you don't need to extend any class or implement any interface -but it eases integration with frameworks if you use the attribute ``#[AsTwigExtension]``:: +Create a class with the attribute ``#[AsTwigExtension]``:: use Twig\Attribute\AsTwigExtension; use Twig\Attribute\AsTwigFilter; @@ -775,7 +774,7 @@ but it eases integration with frameworks if you use the attribute ``#[AsTwigExte use Twig\Attribute\AsTwigTest; #[AsTwigExtension] - class Project_Twig_Extension + class ProjectExtension { #[AsTwigFilter('rot13')] public static function rot13(string $string): string @@ -799,13 +798,7 @@ but it eases integration with frameworks if you use the attribute ``#[AsTwigExte Then register the class using ``Twig\Extension\AttributeExtension``:: $twig = new \Twig\Environment($loader); - $twig->addExtension(new \Twig\Extension\AttributeExtension([ - Project_Twig_Extension::class, - ])); - -.. note:: - - The ``\Twig\Extension\AttributeExtension`` can be added only once to an environment. + $twig->addExtension(ProjectExtension::class); If all the methods are static, you are done. The ``Project_Twig_Extension`` class will never be instantiated and the class attributes will be scanned only when a template @@ -818,7 +811,7 @@ a runtime extension using one of the runtime loaders:: use Twig\Attribute\AsTwigFunction; #[AsTwigExtension] - class Project_Service + class ProjectExtension { // Inject hypothetical dependencies public function __construct(private LipsumProvider $lipsumProvider) {} @@ -831,21 +824,17 @@ a runtime extension using one of the runtime loaders:: } $twig = new \Twig\Environment($loader); - $twig->addExtension(new \Twig\Extension\AttributeExtension([ - Project_Twig_Extension::class, - ])); + $twig->addExtension(ProjectExtension::class); $twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryLoader([ - Project_Twig_Extension::class => function () use ($lipsumProvider) { - return new Project_Twig_Extension($lipsumProvider); + ProjectExtension::class => function () use ($lipsumProvider) { + return new ProjectExtension($lipsumProvider); }, ])); Or use the instance directly if you don't need lazy-loading:: $twig = new \Twig\Environment($loader); - $twig->addExtension(new \Twig\Extension\AttributeExtension([ - new Project_Twig_Extension($lipsumProvider), - ])); + $twig->addExtension(new ProjectExtension($lipsumProvider)); ``#[AsTwigFilter]`` and ``#[AsTwigFunction]`` support ``isSafe``, ``preEscape``, and ``isVariadic`` options:: @@ -855,7 +844,7 @@ Or use the instance directly if you don't need lazy-loading:: use Twig\Attribute\AsTwigFunction; #[AsTwigExtension] - class Project_Twig_Extension + class ProjectExtension { #[AsTwigFilter('rot13', isSafe: ['html'])] public static function rot13(string $string): string @@ -873,7 +862,7 @@ Or use the instance directly if you don't need lazy-loading:: If you want to access the current environment instance in your filter or function, add the ``Twig\Environment`` type to the first argument of the method:: - class Project_Twig_Extension + class ProjectExtension { #[AsTwigFunction('lipsum')] public function lipsum(\Twig\Environment $env, int $count): string @@ -885,7 +874,7 @@ add the ``Twig\Environment`` type to the first argument of the method:: If you want to access the current context in your filter or function, add an argument with type and name ``array $context`` first or after ``\Twig\Environment``:: - class Project_Twig_Extension + class ProjectExtension { #[AsTwigFunction('lipsum')] public function lipsum(array $context, int $count): string @@ -897,7 +886,7 @@ with type and name ``array $context`` first or after ``\Twig\Environment``:: ``#[AsTwigFilter]`` and ``#[AsTwigFunction]`` support variadic arguments automatically when applied to variadic methods:: - class Project_Twig_Extension + class ProjectExtension { #[AsTwigFilter('thumbnail')] public function thumbnail(string $file, mixed ...$options): string diff --git a/src/Environment.php b/src/Environment.php index ec9c39da5ac..5d350bc5179 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -616,14 +616,17 @@ public function getRuntime(string $class) throw new RuntimeError(sprintf('Unable to load the "%s" runtime.', $class)); } - public function addExtension(ExtensionInterface $extension) + /** + * @param ExtensionInterface|object|class-string $extension + */ + public function addExtension(object|string $extension) { $this->extensionSet->addExtension($extension); $this->updateOptionsHash(); } /** - * @param ExtensionInterface[] $extensions An array of extensions + * @param list $extensions An array of extensions */ public function setExtensions(array $extensions) { diff --git a/src/Extension/AttributeExtension.php b/src/Extension/AttributeExtension.php index 1fc64b69eb7..c2ec849b464 100644 --- a/src/Extension/AttributeExtension.php +++ b/src/Extension/AttributeExtension.php @@ -11,6 +11,7 @@ namespace Twig\Extension; +use Twig\Attribute\AsTwigExtension; use Twig\Attribute\AsTwigFilter; use Twig\Attribute\AsTwigFunction; use Twig\Attribute\AsTwigTest; @@ -23,36 +24,25 @@ * Define Twig filters, functions, and tests with PHP attributes. * * @author Jérôme Tamarelle + * + * @internal */ -final class AttributeExtension extends AbstractExtension implements ModificationAwareInterface +final class AttributeExtension extends AbstractExtension { + private array $classes; private array $filters; private array $functions; private array $tests; - public function __construct( - /** - * A list of objects or class names defining filters, functions, and tests using PHP attributes. - * When passing a class name, it must be available in runtimes. - * - * @var iterable - */ - private iterable $objectsOrClasses, - ) { - } - - public function getLastModified(): int + /** + * A list of objects or class names defining filters, functions, and tests using PHP attributes. + * When passing a class name, it must be available in runtimes. + * + * @param class-string[] + */ + public function __construct(array $classes) { - $lastModified = 0; - - foreach ($this->objectsOrClasses as $objectOrClass) { - $r = new \ReflectionClass($objectOrClass); - if (is_file($r->getFileName()) && $lastModified < $extensionTime = filemtime($r->getFileName())) { - $lastModified = $extensionTime; - } - } - - return $lastModified; + $this->classes = $classes; } public function getFilters(): array @@ -86,13 +76,18 @@ private function initFromAttributes() { $filters = $functions = $tests = []; - foreach ($this->objectsOrClasses as $objectOrClass) { + foreach ($this->classes as $objectOrClass) { try { $reflectionClass = new \ReflectionClass($objectOrClass); } catch (\ReflectionException $e) { throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e); } + $attributes = $reflectionClass->getAttributes(AsTwigExtension::class); + if (!$attributes) { + throw new \LogicException(sprintf('Extension class "%s" must have the attribute "%s" in order to use attributes', is_string($objectOrClass) ? $objectOrClass : get_debug_type($objectOrClass), AsTwigExtension::class)); + } + foreach ($reflectionClass->getMethods() as $method) { // Filters foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) { diff --git a/src/Extension/ModificationAwareInterface.php b/src/Extension/ModificationAwareInterface.php deleted file mode 100644 index e85c3ca4ed0..00000000000 --- a/src/Extension/ModificationAwareInterface.php +++ /dev/null @@ -1,15 +0,0 @@ -extensions[$class])) { + throw new RuntimeError(sprintf('The "%s" uses attributes, it requires a runtime.', $class)); + } + return $this->extensions[$class]; } /** - * @param ExtensionInterface[] $extensions + * @param list $extensions */ public function setExtensions(array $extensions): void { @@ -112,11 +117,8 @@ public function getLastModified(): int } foreach ($this->extensions as $extension) { - $r = new \ReflectionObject($extension); - if (is_file($r->getFileName()) && $this->lastModified < $extensionTime = filemtime($r->getFileName())) { - $this->lastModified = $extensionTime; - } - if ($extension instanceof ModificationAwareInterface && $this->lastModified < $extensionTime = $extension->getLastModified()) { + $r = new \ReflectionClass($extension); + if (($filename = $r->getFileName()) && is_file($filename) && $this->lastModified < $extensionTime = filemtime($filename)) { $this->lastModified = $extensionTime; } } @@ -124,9 +126,12 @@ public function getLastModified(): int return $this->lastModified; } - public function addExtension(ExtensionInterface $extension): void + /** + * @param ExtensionInterface|class-string|object $extension + */ + public function addExtension(string|object $extension): void { - $class = \get_class($extension); + $class = is_string($extension) ? $extension : \get_class($extension); if ($this->initialized) { throw new \LogicException(sprintf('Unable to register extension "%s" as extensions have already been initialized.', $class)); @@ -428,9 +433,16 @@ private function initExtensions(): void $this->unaryOperators = []; $this->binaryOperators = []; + $classes = []; foreach ($this->extensions as $extension) { - $this->initExtension($extension); + if ($extension instanceof ExtensionInterface) { + $this->initExtension($extension); + } else { + $classes[] = $extension; + } } + + $this->initExtension(new AttributeExtension($classes)); $this->initExtension($this->staging); // Done at the end only, so that an exception during initialization does not mark the environment as initialized when catching the exception $this->initialized = true; diff --git a/tests/Extension/AttributeExtensionTest.php b/tests/Extension/AttributeExtensionTest.php index e62f15c04b4..14244fbf8e0 100644 --- a/tests/Extension/AttributeExtensionTest.php +++ b/tests/Extension/AttributeExtensionTest.php @@ -107,10 +107,4 @@ public function testRuntimeExtension() $this->assertSame([$class, 'fooFunction'], $extension->getFunctions()['foo']->getCallable()); $this->assertSame([$class, 'fooTest'], $extension->getTests()['foo']->getCallable()); } - - public function testLastModified() - { - $extension = new AttributeExtension([ExtensionWithAttributes::class]); - $this->assertSame(filemtime(__DIR__.'/Fixtures/ExtensionWithAttributes.php'), $extension->getLastModified()); - } }