mirror of
https://github.com/twigphp/Twig.git
synced 2026-09-17 21:07:18 +00:00
feature #3916 Create attributes AsTwigFilter, AsTwigFunction and AsTwigTest to ease extension development (GromNaN)
This PR was squashed before being merged into the 3.x branch.
Discussion
----------
Create attributes `AsTwigFilter`, `AsTwigFunction` and `AsTwigTest` to ease extension development
One drawback to writing extensions at present is that the declaration of functions/filters/tests is not directly adjacent to the methods. It's worse for runtime extensions because they need to be in 2 different classes. See [`SerializerExtension`](https://github.com/symfony/symfony/blob/7.0/src/Symfony/Bridge/Twig/Extension/SerializerExtension.php) and [`SerializerRuntime`](https://github.com/symfony/symfony/blob/7.0/src/Symfony/Bridge/Twig/Extension/SerializerRuntime.php) as an example.
By using attributes for filters, functions and tests definition, we can make writing extensions more expressive, and use reflection to detect particular options (`needs_environment`, `needs_context`, `is_variadic`).
Example if we implemented the `formatDate` filter: https://github.com/twigphp/Twig/blob/aeeec9a5e907a79e50a6bb78979154599401726e/extra/intl-extra/IntlExtension.php#L392-L395
By using the `AsTwigFilter` attribute, it is not necessary to create the `getFilters()` method. The `needs_environment` option is detected from method signature. The name is still required as the method naming convention (camelCase) doesn't match with Twig naming convention (snake_case).
```php
use Twig\Extension\Attribute\AsTwigFilter;
class IntlExtension
{
#[AsTwigFilter(name: 'format_date')]
public function formatDate(Environment $env, $date, ?string $dateFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', string $locale = null): string
{
return $this->formatDateTime($env, $date, $dateFormat, 'none', $pattern, $timezone, $calendar, $locale);
}
}
```
This approach does not totally replace the current definition of extensions, which is still necessary for advanced needs. It does, however, make for more pleasant reading and writing.
This makes writing lazy-loaded runtime extension the easiest way to create Twig extension in Symfony: https://github.com/symfony/symfony/pull/52748
Related to https://github.com/symfony/symfony/issues/50016
Is there any need to cache the parsing of method attributes? They are only read at compile time, but that can have a performance impact during development or when using dynamic templates.
Commits
-------
5886907b28 Create attributes `AsTwigFilter`, `AsTwigFunction` and `AsTwigTest` to ease extension development
This commit is contained in:
@@ -814,6 +814,107 @@ The ``getTests()`` method lets you add new test functions::
|
||||
// ...
|
||||
}
|
||||
|
||||
Using PHP Attributes to define Extensions
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. versionadded:: 3.21
|
||||
|
||||
The attribute classes were added in Twig 3.21.
|
||||
|
||||
You can add the ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and ``#[AsTwigTest]``
|
||||
attributes to public methods of any class to define filters, functions, and tests.
|
||||
|
||||
Create a class using these 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(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
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Attribute;
|
||||
|
||||
use Twig\DeprecatedCallableInfo;
|
||||
use Twig\TwigFilter;
|
||||
|
||||
/**
|
||||
* Registers a method as template filter.
|
||||
*
|
||||
* If the first argument of the method has Twig\Environment type-hint, the filter will receive the current environment.
|
||||
* Additional arguments of the method come from the filter call.
|
||||
*
|
||||
* #[AsTwigFilter(name: 'foo')]
|
||||
* function fooFilter(Environment $env, $string, $arg1 = null, ...) { ... }
|
||||
*
|
||||
* {{ 'string'|foo(arg1) }}
|
||||
*
|
||||
* @see TwigFilter
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
|
||||
final class AsTwigFilter
|
||||
{
|
||||
/**
|
||||
* @param non-empty-string $name The name of the filter in Twig.
|
||||
* @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument.
|
||||
* @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset.
|
||||
* @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment.
|
||||
* @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped.
|
||||
* @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe.
|
||||
* @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
|
||||
* @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to.
|
||||
* @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
|
||||
*/
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public ?bool $needsCharset = null,
|
||||
public ?bool $needsEnvironment = null,
|
||||
public ?bool $needsContext = null,
|
||||
public ?array $isSafe = null,
|
||||
public string|array|null $isSafeCallback = null,
|
||||
public ?string $preEscape = null,
|
||||
public ?array $preservesSafety = null,
|
||||
public ?DeprecatedCallableInfo $deprecationInfo = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Attribute;
|
||||
|
||||
use Twig\DeprecatedCallableInfo;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* Registers a method as template function.
|
||||
*
|
||||
* If the first argument of the method has Twig\Environment type-hint, the function will receive the current environment.
|
||||
* Additional arguments of the method come from the function call.
|
||||
*
|
||||
* #[AsTwigFunction(name: 'foo')]
|
||||
* function fooFunction(Environment $env, string $string, $arg1 = null, ...) { ... }
|
||||
*
|
||||
* {{ foo('string', arg1) }}
|
||||
*
|
||||
* @see TwigFunction
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
|
||||
final class AsTwigFunction
|
||||
{
|
||||
/**
|
||||
* @param non-empty-string $name The name of the function in Twig.
|
||||
* @param bool|null $needsCharset Whether the function needs the charset passed as the first argument.
|
||||
* @param bool|null $needsEnvironment Whether the function needs the environment passed as the first argument, or after the charset.
|
||||
* @param bool|null $needsContext Whether the function needs the context array passed as the first argument, or after the charset and the environment.
|
||||
* @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped.
|
||||
* @param string|array|null $isSafeCallback Function called at compilation time to determine if the function is safe.
|
||||
* @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
|
||||
*/
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public ?bool $needsCharset = null,
|
||||
public ?bool $needsEnvironment = null,
|
||||
public ?bool $needsContext = null,
|
||||
public ?array $isSafe = null,
|
||||
public string|array|null $isSafeCallback = null,
|
||||
public ?DeprecatedCallableInfo $deprecationInfo = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Attribute;
|
||||
|
||||
use Twig\DeprecatedCallableInfo;
|
||||
use Twig\TwigTest;
|
||||
|
||||
/**
|
||||
* Registers a method as template test.
|
||||
*
|
||||
* The first argument is the value to test and the other arguments are the
|
||||
* arguments passed to the test in the template.
|
||||
*
|
||||
* #[AsTwigTest(name: 'foo')]
|
||||
* public function fooTest($value, $arg1 = null) { ... }
|
||||
*
|
||||
* {% if value is foo(arg1) %}
|
||||
*
|
||||
* @see TwigTest
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
|
||||
final class AsTwigTest
|
||||
{
|
||||
/**
|
||||
* @param non-empty-string $name The name of the test in Twig.
|
||||
* @param bool|null $needsCharset Whether the test needs the charset passed as the first argument.
|
||||
* @param bool|null $needsEnvironment Whether the test needs the environment passed as the first argument, or after the charset.
|
||||
* @param bool|null $needsContext Whether the test needs the context array passed as the first argument, or after the charset and the environment.
|
||||
* @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
|
||||
*/
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public ?bool $needsCharset = null,
|
||||
public ?bool $needsEnvironment = null,
|
||||
public ?bool $needsContext = null,
|
||||
public ?DeprecatedCallableInfo $deprecationInfo = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Extension;
|
||||
|
||||
use Twig\Attribute\AsTwigFilter;
|
||||
use Twig\Attribute\AsTwigFunction;
|
||||
use Twig\Attribute\AsTwigTest;
|
||||
use Twig\Environment;
|
||||
use Twig\TwigFilter;
|
||||
use Twig\TwigFunction;
|
||||
use Twig\TwigTest;
|
||||
|
||||
/**
|
||||
* Define Twig filters, functions, and tests with PHP attributes.
|
||||
*
|
||||
* @author Jérôme Tamarelle <jerome@tamarelle.net>
|
||||
*/
|
||||
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->getClass()))->getFileName()) ? filemtime($filename) : 0,
|
||||
);
|
||||
}
|
||||
|
||||
private function initFromAttributes(): void
|
||||
{
|
||||
$filters = $functions = $tests = [];
|
||||
$reflectionClass = new \ReflectionClass($this->getClass());
|
||||
foreach ($reflectionClass->getMethods() as $method) {
|
||||
foreach ($method->getAttributes(AsTwigFilter::class) as $reflectionAttribute) {
|
||||
/** @var AsTwigFilter $attribute */
|
||||
$attribute = $reflectionAttribute->newInstance();
|
||||
|
||||
$callable = new TwigFilter($attribute->name, [$reflectionClass->name, $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, [$reflectionClass->name, $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, [$reflectionClass->name, $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();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use Twig\ExpressionParser\InfixAssociativity;
|
||||
use Twig\ExpressionParser\InfixExpressionParserInterface;
|
||||
use Twig\ExpressionParser\PrecedenceChange;
|
||||
use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser;
|
||||
use Twig\Extension\AttributeExtension;
|
||||
use Twig\Extension\ExtensionInterface;
|
||||
use Twig\Extension\GlobalsInterface;
|
||||
use Twig\Extension\LastModifiedExtensionInterface;
|
||||
@@ -142,7 +143,11 @@ final class ExtensionSet
|
||||
|
||||
public function addExtension(ExtensionInterface $extension): void
|
||||
{
|
||||
$class = $extension::class;
|
||||
if ($extension instanceof AttributeExtension) {
|
||||
$class = $extension->getClass();
|
||||
} else {
|
||||
$class = $extension::class;
|
||||
}
|
||||
|
||||
if ($this->initialized) {
|
||||
throw new \LogicException(\sprintf('Unable to register extension "%s" as extensions have already been initialized.', $class));
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace Twig\Tests\Extension;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Twig\DeprecatedCallableInfo;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Extension\AttributeExtension;
|
||||
use Twig\ExtensionSet;
|
||||
use Twig\Tests\Extension\Fixtures\FilterWithoutValue;
|
||||
use Twig\Tests\Extension\Fixtures\TestWithoutValue;
|
||||
use Twig\Tests\Extension\Fixtures\ExtensionWithAttributes;
|
||||
use Twig\TwigFilter;
|
||||
use Twig\TwigFunction;
|
||||
use Twig\TwigTest;
|
||||
|
||||
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, '<?php class __CLASS_FOR_TEST_LAST_MODIFIED__ {}');
|
||||
require $filename;
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Twig\Tests\Extension\Fixtures;
|
||||
|
||||
use Twig\Attribute\AsTwigFilter;
|
||||
use Twig\Attribute\AsTwigFunction;
|
||||
use Twig\Attribute\AsTwigTest;
|
||||
use Twig\DeprecatedCallableInfo;
|
||||
use Twig\Environment;
|
||||
|
||||
class ExtensionWithAttributes
|
||||
{
|
||||
#[AsTwigFilter(name: 'foo', isSafe: ['html'])]
|
||||
public function fooFilter(string|int $string)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigFilter('with_context_filter', needsContext: true)]
|
||||
public function withContextFilter(array $context, string $string)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigFilter('with_env_filter')]
|
||||
public function withEnvFilter(Environment $env, string $string)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigFilter('with_env_and_context_filter', needsContext: true)]
|
||||
public function withEnvAndContextFilter(Environment $env, array $context, array $data)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigFilter('variadic_filter')]
|
||||
public function variadicFilter(string ...$strings)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigFilter('deprecated_filter', deprecationInfo: new DeprecatedCallableInfo('foo/bar', '1.2'))]
|
||||
public function deprecatedFilter(string $string)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigFilter('pattern_*_filter')]
|
||||
public function patternFilter(string $string)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigFunction(name: 'foo', isSafe: ['html'])]
|
||||
public function fooFunction(string|int $string)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigFunction('with_context_function', needsContext: true)]
|
||||
public function withContextFunction(array $context, string $string)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigFunction('with_env_function')]
|
||||
public function withEnvFunction(Environment $env, string $string)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigFunction('with_env_and_context_function', needsContext: true)]
|
||||
public function withEnvAndContextFunction(Environment $env, array $context, string $string)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigFunction('no_arg_function')]
|
||||
public function noArgFunction()
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigFunction('variadic_function')]
|
||||
public function variadicFunction(string ...$strings)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigFunction('deprecated_function', deprecationInfo: new DeprecatedCallableInfo('foo/bar', '1.2'))]
|
||||
public function deprecatedFunction(string $string)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigTest(name: 'foo')]
|
||||
public function fooTest(string|int $value)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigTest('variadic_test')]
|
||||
public function variadicTest(string ...$value)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigTest('with_context_test', needsContext: true)]
|
||||
public function withContextTest(array $context, $argument)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigTest('with_env_test')]
|
||||
public function withEnvTest(Environment $env, $argument)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigTest('with_env_and_context_test', needsContext: true)]
|
||||
public function withEnvAndContextTest(Environment $env, array $context, $argument)
|
||||
{
|
||||
}
|
||||
|
||||
#[AsTwigTest('deprecated_test', deprecationInfo: new DeprecatedCallableInfo('foo/bar', '1.2'))]
|
||||
public function deprecatedTest($value, $argument)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Twig\Tests\Extension\Fixtures;
|
||||
|
||||
use Twig\Attribute\AsTwigFilter;
|
||||
|
||||
class FilterWithoutValue
|
||||
{
|
||||
#[AsTwigFilter('my_filter')]
|
||||
public function myFilter()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Twig\Tests\Extension\Fixtures;
|
||||
|
||||
use Twig\Attribute\AsTwigTest;
|
||||
|
||||
class TestWithoutValue
|
||||
{
|
||||
#[AsTwigTest('my_test')]
|
||||
public function myTest()
|
||||
{
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user