diff --git a/doc/advanced.rst b/doc/advanced.rst index bc7e5d376f4..c102fd553a2 100644 --- a/doc/advanced.rst +++ b/doc/advanced.rst @@ -813,6 +813,110 @@ The ``getTests()`` method lets you add new test functions:: // ... } +Using PHP Attributes to define Extensions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 3.19 + + The attribute classes were added in Twig 3.19. + +You can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and +``#[AsTwigTest]`` on any method of any class to define filters, functions, and tests. + +Create a class using this attributes:: + + use Twig\Attribute\AsTwigFilter; + use Twig\Attribute\AsTwigFunction; + use Twig\Attribute\AsTwigTest; + + class ProjectExtension + { + #[AsTwigFilter('rot13')] + public static function rot13(string $string): string + { + // ... + } + + #[AsTwigFunction('lipsum')] + public static function lipsum(int $count): string + { + // ... + } + + #[AsTwigTest('even')] + public static function isEven(int $number): bool + { + // ... + } + } + +Then register the ``Twig\Extension\AttributeExtension`` with the class name:: + + $twig = new \Twig\Environment($loader); + $twig->addExtension(new \Twig\Extension\AttributeExtension([ + // List of all the classes using AsTwig attributes + ProjectExtension::class, + ]); + +If all the methods are static, you are done. The ``ProjectExtension`` class will +never be instantiated and the class attributes will be scanned only when a template +is compiled. + +Otherwise, if some methods are not static, you need to register the class as +a runtime extension using one of the runtime loaders:: + + use Twig\Attribute\AsTwigFunction; + + class ProjectExtension + { + // Inject hypothetical dependencies + public function __construct(private LipsumProvider $lipsumProvider) {} + + #[AsTwigFunction('lipsum')] + public function lipsum(int $count): string + { + return $this->lipsumProvider->lipsum($count); + } + } + + $twig = new \Twig\Environment($loader); + $twig->addExtension(new \Twig\Extension\AttributeExtension([ProjectExtension::class]); + $twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryLoader([ + ProjectExtension::class => function () use ($lipsumProvider) { + return new ProjectExtension($lipsumProvider); + }, + ])); + +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 ProjectExtension + { + #[AsTwigFunction('lipsum')] + public function lipsum(\Twig\Environment $env, int $count): string + { + // ... + } + } + +``#[AsTwigFilter]`` and ``#[AsTwigFunction]`` support variadic arguments +automatically when applied to variadic methods:: + + class ProjectExtension + { + #[AsTwigFilter('thumbnail')] + public function thumbnail(string $file, mixed ...$options): string + { + // ... + } + } + +The attributes support other options used to configure the Twig Callables: + + * ``AsTwigFilter``: ``needsCharset``, ``needsEnvironment``, ``needsContext``, ``isSafe``, ``isSafeCallback``, ``preEscape``, ``preservesSafety``, ``deprecationInfo`` + * ``AsTwigFunction``: ``needsCharset``, ``needsEnvironment``, ``needsContext``, ``isSafe``, ``isSafeCallback``, ``deprecationInfo`` + * ``AsTwigTest``: ``needsCharset``, ``needsEnvironment``, ``needsContext``, ``deprecationInfo`` + Definition vs Runtime ~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/Attribute/AsTwigFilter.php b/src/Attribute/AsTwigFilter.php new file mode 100644 index 00000000000..9313bf6cc00 --- /dev/null +++ b/src/Attribute/AsTwigFilter.php @@ -0,0 +1,57 @@ + + */ +final class AttributeExtension extends AbstractExtension +{ + private array $filters; + private array $functions; + private array $tests; + + /** + * Use a runtime class using PHP attributes to define filters, functions, and tests. + * + * @param class-string $class + */ + public function __construct(private string $class) + { + } + + /** + * @return class-string + */ + public function getClass(): string + { + return $this->class; + } + + public function getFilters(): array + { + if (!isset($this->filters)) { + $this->initFromAttributes(); + } + + return $this->filters; + } + + public function getFunctions(): array + { + if (!isset($this->functions)) { + $this->initFromAttributes(); + } + + return $this->functions; + } + + public function getTests(): array + { + if (!isset($this->tests)) { + $this->initFromAttributes(); + } + + return $this->tests; + } + + public function getLastModified(): int + { + return max( + filemtime(__FILE__), + is_file($filename = (new \ReflectionClass($this->class))->getFileName()) ? filemtime($filename) : 0, + ); + } + + private function initFromAttributes(): void + { + $filters = $functions = $tests = []; + $reflectionClass = new \ReflectionClass($this->class); + foreach ($reflectionClass->getMethods() as $method) { + foreach ($method->getAttributes(AsTwigFilter::class) as $reflectionAttribute) { + /** @var AsTwigFilter $attribute */ + $attribute = $reflectionAttribute->newInstance(); + + $callable = new TwigFilter($attribute->name, [$this->class, $method->getName()], [ + 'needs_context' => $attribute->needsContext ?? false, + 'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method), + 'needs_charset' => $attribute->needsCharset ?? false, + 'is_variadic' => $method->isVariadic(), + 'is_safe' => $attribute->isSafe, + 'is_safe_callback' => $attribute->isSafeCallback, + 'pre_escape' => $attribute->preEscape, + 'preserves_safety' => $attribute->preservesSafety, + 'deprecation_info' => $attribute->deprecationInfo, + ]); + + if ($callable->getMinimalNumberOfRequiredArguments() > $method->getNumberOfParameters()) { + throw new \LogicException(sprintf('"%s::%s()" needs at least %d arguments to be used AsTwigFilter, but only %d defined.', $reflectionClass->getName(), $method->getName(), $callable->getMinimalNumberOfRequiredArguments(), $method->getNumberOfParameters())); + } + + $filters[$attribute->name] = $callable; + } + + foreach ($method->getAttributes(AsTwigFunction::class) as $reflectionAttribute) { + /** @var AsTwigFunction $attribute */ + $attribute = $reflectionAttribute->newInstance(); + + $callable = new TwigFunction($attribute->name, [$this->class, $method->getName()], [ + 'needs_context' => $attribute->needsContext ?? false, + 'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method), + 'needs_charset' => $attribute->needsCharset ?? false, + 'is_variadic' => $method->isVariadic(), + 'is_safe' => $attribute->isSafe, + 'is_safe_callback' => $attribute->isSafeCallback, + 'deprecation_info' => $attribute->deprecationInfo, + ]); + + if ($callable->getMinimalNumberOfRequiredArguments() > $method->getNumberOfParameters()) { + throw new \LogicException(sprintf('"%s::%s()" needs at least %d arguments to be used AsTwigFunction, but only %d defined.', $reflectionClass->getName(), $method->getName(), $callable->getMinimalNumberOfRequiredArguments(), $method->getNumberOfParameters())); + } + + $functions[$attribute->name] = $callable; + } + + foreach ($method->getAttributes(AsTwigTest::class) as $reflectionAttribute) { + + /** @var AsTwigTest $attribute */ + $attribute = $reflectionAttribute->newInstance(); + + $callable = new TwigTest($attribute->name, [$this->class, $method->getName()], [ + 'needs_context' => $attribute->needsContext ?? false, + 'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method), + 'needs_charset' => $attribute->needsCharset ?? false, + 'is_variadic' => $method->isVariadic(), + 'deprecation_info' => $attribute->deprecationInfo, + ]); + + if ($callable->getMinimalNumberOfRequiredArguments() > $method->getNumberOfParameters()) { + throw new \LogicException(sprintf('"%s::%s()" needs at least %d arguments to be used AsTwigTest, but only %d defined.', $reflectionClass->getName(), $method->getName(), $callable->getMinimalNumberOfRequiredArguments(), $method->getNumberOfParameters())); + } + + $tests[$attribute->name] = $callable; + } + } + + // Assign all at the end to avoid inconsistent state in case of exception + $this->filters = array_values($filters); + $this->functions = array_values($functions); + $this->tests = array_values($tests); + } + + /** + * Detect if the first argument of the method is the environment. + */ + private function needsEnvironment(\ReflectionFunctionAbstract $function): bool + { + if (!$parameters = $function->getParameters()) { + return false; + } + + return $parameters[0]->getType() instanceof \ReflectionNamedType + && Environment::class === $parameters[0]->getType()->getName() + && !$parameters[0]->isVariadic(); + } +} diff --git a/src/ExtensionSet.php b/src/ExtensionSet.php index 18979228a7d..e567ed52325 100644 --- a/src/ExtensionSet.php +++ b/src/ExtensionSet.php @@ -12,6 +12,7 @@ namespace Twig; use Twig\Error\RuntimeError; +use Twig\Extension\AttributeExtension; use Twig\Extension\ExtensionInterface; use Twig\Extension\GlobalsInterface; use Twig\Extension\LastModifiedExtensionInterface; @@ -134,7 +135,11 @@ public function getLastModified(): int public function addExtension(ExtensionInterface $extension): void { - $class = \get_class($extension); + if ($extension instanceof AttributeExtension) { + $class = $extension->getClass(); + } else { + $class = \get_class($extension); + } if ($this->initialized) { throw new \LogicException(\sprintf('Unable to register extension "%s" as extensions have already been initialized.', $class)); diff --git a/tests/Extension/AttributeExtensionTest.php b/tests/Extension/AttributeExtensionTest.php new file mode 100644 index 00000000000..26c5f10bbe5 --- /dev/null +++ b/tests/Extension/AttributeExtensionTest.php @@ -0,0 +1,164 @@ += 8.1 + */ +class AttributeExtensionTest extends TestCase +{ + /** + * @dataProvider provideFilters + */ + public function testFilter(string $name, string $method, array $options) + { + $extension = new AttributeExtension(ExtensionWithAttributes::class); + foreach ($extension->getFilters() as $filter) { + if ($filter->getName() === $name) { + $this->assertEquals(new TwigFilter($name, [ExtensionWithAttributes::class, $method], $options), $filter); + + return; + } + } + + $this->fail(sprintf('Filter "%s" is not registered.', $name)); + } + + public static function provideFilters() + { + yield 'with name' => ['foo', 'fooFilter', ['is_safe' => ['html']]]; + yield 'with env' => ['with_env_filter', 'withEnvFilter', ['needs_environment' => true]]; + yield 'with context' => ['with_context_filter', 'withContextFilter', ['needs_context' => true]]; + yield 'with env and context' => ['with_env_and_context_filter', 'withEnvAndContextFilter', ['needs_environment' => true, 'needs_context' => true]]; + yield 'variadic' => ['variadic_filter', 'variadicFilter', ['is_variadic' => true]]; + yield 'deprecated' => ['deprecated_filter', 'deprecatedFilter', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]]; + yield 'pattern' => ['pattern_*_filter', 'patternFilter', []]; + } + + /** + * @dataProvider provideFunctions + */ + public function testFunction(string $name, string $method, array $options) + { + $extension = new AttributeExtension(ExtensionWithAttributes::class); + foreach ($extension->getFunctions() as $function) { + if ($function->getName() === $name) { + $this->assertEquals(new TwigFunction($name, [ExtensionWithAttributes::class, $method], $options), $function); + + return; + } + } + + $this->fail(sprintf('Function "%s" is not registered.', $name)); + } + + public static function provideFunctions() + { + yield 'with name' => ['foo', 'fooFunction', ['is_safe' => ['html']]]; + yield 'with env' => ['with_env_function', 'withEnvFunction', ['needs_environment' => true]]; + yield 'with context' => ['with_context_function', 'withContextFunction', ['needs_context' => true]]; + yield 'with env and context' => ['with_env_and_context_function', 'withEnvAndContextFunction', ['needs_environment' => true, 'needs_context' => true]]; + yield 'no argument' => ['no_arg_function', 'noArgFunction', []]; + yield 'variadic' => ['variadic_function', 'variadicFunction', ['is_variadic' => true]]; + yield 'deprecated' => ['deprecated_function', 'deprecatedFunction', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]]; + } + + /** + * @dataProvider provideTests + */ + public function testTest(string $name, string $method, array $options) + { + $extension = new AttributeExtension(ExtensionWithAttributes::class); + foreach ($extension->getTests() as $test) { + if ($test->getName() === $name) { + $this->assertEquals(new TwigTest($name, [ExtensionWithAttributes::class, $method], $options), $test); + + return; + } + } + + $this->fail(sprintf('Test "%s" is not registered.', $name)); + } + + public static function provideTests() + { + yield 'with name' => ['foo', 'fooTest', []]; + yield 'with env' => ['with_env_test', 'withEnvTest', ['needs_environment' => true]]; + yield 'with context' => ['with_context_test', 'withContextTest', ['needs_context' => true]]; + yield 'with env and context' => ['with_env_and_context_test', 'withEnvAndContextTest', ['needs_environment' => true, 'needs_context' => true]]; + yield 'variadic' => ['variadic_test', 'variadicTest', ['is_variadic' => true]]; + yield 'deprecated' => ['deprecated_test', 'deprecatedTest', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]]; + } + + public function testFilterRequireOneArgument() + { + $extension = new AttributeExtension(FilterWithoutValue::class); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('"'.FilterWithoutValue::class.'::myFilter()" needs at least 1 arguments to be used AsTwigFilter, but only 0 defined.'); + + $extension->getTests(); + } + + public function testTestRequireOneArgument() + { + $extension = new AttributeExtension(TestWithoutValue::class); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('"'.TestWithoutValue::class.'::myTest()" needs at least 1 arguments to be used AsTwigTest, but only 0 defined.'); + + $extension->getTests(); + } + + public function testLastModifiedWithObject() + { + $extension = new AttributeExtension(\stdClass::class); + + $this->assertSame(filemtime((new \ReflectionClass(AttributeExtension::class))->getFileName()), $extension->getLastModified()); + } + + public function testLastModifiedWithClass() + { + $extension = new AttributeExtension('__CLASS_FOR_TEST_LAST_MODIFIED__'); + + $filename = tempnam(sys_get_temp_dir(), 'twig'); + try { + file_put_contents($filename, 'assertSame(filemtime($filename), $extension->getLastModified()); + } finally { + unlink($filename); + } + } + + public function testMultipleRegistrations() + { + $extensionSet = new ExtensionSet(); + $extensionSet->addExtension($extension1 = new AttributeExtension(ExtensionWithAttributes::class)); + $extensionSet->addExtension($extension2 = new AttributeExtension(\stdClass::class)); + + $this->assertCount(2, $extensionSet->getExtensions()); + $this->assertNotNull($extensionSet->getFilter('foo')); + + $this->assertSame($extension1, $extensionSet->getExtension(ExtensionWithAttributes::class)); + $this->assertSame($extension2, $extensionSet->getExtension(\stdClass::class)); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessage('The "Twig\Extension\AttributeExtension" extension is not enabled.'); + $extensionSet->getExtension(AttributeExtension::class); + } +} diff --git a/tests/Extension/Fixtures/ExtensionWithAttributes.php b/tests/Extension/Fixtures/ExtensionWithAttributes.php new file mode 100644 index 00000000000..2e6b0ee1011 --- /dev/null +++ b/tests/Extension/Fixtures/ExtensionWithAttributes.php @@ -0,0 +1,112 @@ +