Create attributes AsTwigFilter, AsTwigFunction and AsTwigTest to ease extension development

This commit is contained in:
Jérôme Tamarelle
2023-11-26 00:43:09 +01:00
committed by Fabien Potencier
parent 8440cf4ac2
commit 5886907b28
10 changed files with 735 additions and 1 deletions
+101
View File
@@ -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
~~~~~~~~~~~~~~~~~~~~~
+56
View File
@@ -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,
) {
}
}
+52
View File
@@ -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,
) {
}
}
+48
View File
@@ -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,
) {
}
}
+174
View File
@@ -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();
}
}
+6 -1
View File
@@ -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));
+160
View File
@@ -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()
{
}
}