diff --git a/src/Attribute/AsTwigFilter.php b/src/Attribute/AsTwigFilter.php index 3a9f2abcf52..1ebb325c488 100644 --- a/src/Attribute/AsTwigFilter.php +++ b/src/Attribute/AsTwigFilter.php @@ -25,46 +25,30 @@ * #[AsTwigFilter('foo')] * function fooFilter(Environment $env, array $context, $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 string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped. + * @param null|callable(Node):bool $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, for + * example when adding (safe) HTML tags to originally unsafe output. In such a + * case, set preEscape to an escape format to escape the input data before it + * is run through the filter. + * @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( - /** - * The name of the filter in Twig. - * - * @var non-empty-string $name - */ public string $name, - - /** - * List of formats in which you want the raw output to be printed unescaped. - * - * @var list|null $isSafe - */ public ?array $isSafe = null, - - /** - * Function called at compilation time to determine if the filter is safe. - * - * @var callable(Node):bool $isSafeCallback - */ - public ?string $isSafeCallback = null, - - /** - * Some filters may need to work on input that is already escaped or safe, for - * example when adding (safe) HTML tags to originally unsafe output. In such a - * case, set preEscape to an escape format to escape the input data before it - * is run through the filter. - */ + public mixed $isSafeCallback = null, public ?string $preEscape = null, - - /** - * Preserves the safety of the value that the filter is applied to. - */ public ?array $preservesSafety = null, - public ?DeprecatedCallableInfo $deprecationInfo = null, ) { } diff --git a/src/Attribute/AsTwigFunction.php b/src/Attribute/AsTwigFunction.php index bc644df6f81..a35f49b7a66 100644 --- a/src/Attribute/AsTwigFunction.php +++ b/src/Attribute/AsTwigFunction.php @@ -23,36 +23,26 @@ * Additional arguments of the method come from the function call. * * #[AsTwigFunction('foo')] - * function fooFunction(Environment $env, array $context, $string, $arg1 = null, ...) { ... } + * function fooFunction(Environment $env, array $context, 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 string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped. + * @param null|callable(Node):bool $isSafeCallback Function called at compilation time to determine if the function is safe. + * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation + */ public function __construct( - /** - * The name of the function in Twig. - * - * @var non-empty-string $name - */ public string $name, - - /** - * List of formats in which you want the raw output to be printed unescaped. - * - * @var list|null $isSafe - */ public ?array $isSafe = null, - - /** - * Function called at compilation time to determine if the function is safe. - * - * @var callable(Node):bool $isSafeCallback - */ - public ?string $isSafeCallback = null, - - public ?DeprecatedCallableInfo $deprecationInfo = null, + public mixed $isSafeCallback = null, + public ?DeprecatedCallableInfo $deprecationInfo = null, ) { } } diff --git a/src/Attribute/AsTwigTest.php b/src/Attribute/AsTwigTest.php index 5ee752b6680..cb9e96988bc 100644 --- a/src/Attribute/AsTwigTest.php +++ b/src/Attribute/AsTwigTest.php @@ -17,26 +17,25 @@ /** * Registers a method as template test. * - * If the first argument of the method has Twig\Environment type-hint, the test will receive the current environment. - * If the next argument of the method is named $context and has array type-hint, the test will receive the current context. - * The last argument of the method is the value to be tested, if any. + * The first argument is the value to test and the other arguments are the + * arguments passed to the test in the template. * * #[AsTwigTest('foo')] - * public function fooTest(Environment $env, array $context, $value, $arg1 = null) { ... } + * 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 DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation + */ public function __construct( - /** - * The name of the filter in Twig. - * - * @var non-empty-string $name - */ public string $name, - public ?DeprecatedCallableInfo $deprecationInfo = null, ) { } diff --git a/src/Extension/AttributeExtension.php b/src/Extension/AttributeExtension.php index 6a7bbfa16d2..ae821d20997 100644 --- a/src/Extension/AttributeExtension.php +++ b/src/Extension/AttributeExtension.php @@ -38,7 +38,7 @@ final class AttributeExtension extends AbstractExtension * 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[] + * @param list $classes */ public function __construct(array $classes) { @@ -77,15 +77,10 @@ private function initFromAttributes() $filters = $functions = $tests = []; 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); - } - + $reflectionClass = new \ReflectionClass($objectOrClass); $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)); + throw new \LogicException(sprintf('Extension class "%s" must have the attribute "#[%s]" in order to use attributes.', $reflectionClass->getName(), AsTwigExtension::class)); } foreach ($reflectionClass->getMethods() as $method) { @@ -96,11 +91,19 @@ private function initFromAttributes() $name = $attribute->name; $parameters = $method->getParameters(); - $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName(); + $needsEnvironment = isset($parameters[0]) + && $parameters[0]->getType() instanceof \ReflectionNamedType + && Environment::class === $parameters[0]->getType()->getName(); $firstParam = $needsEnvironment ? 1 : 0; - $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName(); + $needsContext = isset($parameters[$firstParam]) + && 'context' === $parameters[$firstParam]->getName() + && $parameters[$firstParam]->getType() instanceof \ReflectionNamedType + && 'array' === $parameters[$firstParam]->getType()->getName(); $firstParam += $needsContext ? 1 : 0; - $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic(); + if (!isset($parameters[$firstParam])) { + throw new \LogicException(sprintf('The method "%s::%s()" class must have at least one argument for the value to filter.', $reflectionClass->getName(), $method->getName())); + } + $isVariadic = end($parameters)->isVariadic(); $filters[$name] = new TwigFilter($name, [$objectOrClass, $method->getName()], [ 'needs_environment' => $needsEnvironment, @@ -121,11 +124,16 @@ private function initFromAttributes() $name = $attribute->name; $parameters = $method->getParameters(); - $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName(); + $needsEnvironment = isset($parameters[0]) + && $parameters[0]->getType() instanceof \ReflectionNamedType + && Environment::class === $parameters[0]->getType()->getName(); $firstParam = $needsEnvironment ? 1 : 0; - $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName(); + $needsContext = isset($parameters[$firstParam]) + && $parameters[$firstParam]->getType() instanceof \ReflectionNamedType + && 'array' === $parameters[$firstParam]->getType()->getName(); $firstParam += $needsContext ? 1 : 0; - $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic(); + $isVariadic = isset($parameters[$firstParam]) + && end($parameters)->isVariadic(); $functions[$name] = new TwigFunction($name, [$objectOrClass, $method->getName()], [ 'needs_environment' => $needsEnvironment, @@ -144,7 +152,10 @@ private function initFromAttributes() $name = $attribute->name; $parameters = $method->getParameters(); - $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic(); + if (count($parameters) < 1) { + throw new \LogicException(sprintf('The method "%s::%s()" class must have at least one argument for the value to test.', $reflectionClass->getName(), $method->getName())); + } + $isVariadic = end($parameters)->isVariadic(); $tests[$name] = new TwigTest($name, [$objectOrClass, $method->getName()], [ 'is_variadic' => $isVariadic, diff --git a/tests/Extension/AttributeExtensionTest.php b/tests/Extension/AttributeExtensionTest.php index 7a5c815ecec..4791e820481 100644 --- a/tests/Extension/AttributeExtensionTest.php +++ b/tests/Extension/AttributeExtensionTest.php @@ -5,6 +5,8 @@ use PHPUnit\Framework\TestCase; use Twig\DeprecatedCallableInfo; use Twig\Extension\AttributeExtension; +use Twig\Tests\Extension\Fixtures\FilterWithoutValue; +use Twig\Tests\Extension\Fixtures\TestWithoutValue; use Twig\Tests\Extension\Fixtures\ExtensionWithAttributes; use Twig\TwigFilter; use Twig\TwigFunction; @@ -39,8 +41,8 @@ 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 'no context' => ['no_context_filter', 'noContextFilter', []]; yield 'with env and context' => ['with_env_and_context_filter', 'withEnvAndContextFilter', ['needs_environment' => true, 'needs_context' => true]]; - yield 'no argument' => ['no_arg_filter', 'noArgFilter', []]; 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', []]; @@ -69,6 +71,7 @@ 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 'no context' => ['no_context_function', 'noContextFunction', []]; 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]]; @@ -109,4 +112,34 @@ public function testRuntimeExtension() $this->assertSame([$class, 'fooFunction'], $extension->getFunctions()[0]->getCallable()); $this->assertSame([$class, 'fooTest'], $extension->getTests()[0]->getCallable()); } + + public function testTwigExtensionAttributeIsRequired() + { + $extension = new AttributeExtension([self::class]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(sprintf('Extension class "%s" must have the attribute "#[Twig\Attribute\AsTwigExtension]" in order to use attributes', self::class)); + + $extension->getFilters(); + } + + public function testFilterRequireOneArgument() + { + $extension = new AttributeExtension([FilterWithoutValue::class]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The method "'.FilterWithoutValue::class.'::myFilter()" class must have at least one argument for the value to filter'); + + $extension->getTests(); + } + + public function testTestRequireOneArgument() + { + $extension = new AttributeExtension([TestWithoutValue::class]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The method "'.TestWithoutValue::class.'::myTest()" class must have at least one argument for the value to test'); + + $extension->getTests(); + } } diff --git a/tests/Extension/Fixtures/ExtensionWithAttributes.php b/tests/Extension/Fixtures/ExtensionWithAttributes.php index 64a73264107..b07fab7cbed 100644 --- a/tests/Extension/Fixtures/ExtensionWithAttributes.php +++ b/tests/Extension/Fixtures/ExtensionWithAttributes.php @@ -13,7 +13,7 @@ class ExtensionWithAttributes { #[AsTwigFilter(name: 'foo', isSafe: ['html'])] - public function fooFilter(string $string) + public function fooFilter(string|int $string) { } @@ -22,18 +22,18 @@ public function withContextFilter(array $context, string $string) { } - #[AsTwigFilter('with_env_filter')] - public function withEnvFilter(Environment $env, string $string) + #[AsTwigFilter('no_context_filter')] + public function noContextFilter($context) { } - #[AsTwigFilter('with_env_and_context_filter')] - public function withEnvAndContextFilter(Environment $env, array $context, string $string) + #[AsTwigFilter('with_env_filter')] + public function withEnvFilter(Environment $env, string $string) { } - #[AsTwigFilter('no_arg_filter')] - public function noArgFilter() + #[AsTwigFilter('with_env_and_context_filter')] + public function withEnvAndContextFilter(Environment $env, array $context, array $data) { } @@ -53,7 +53,7 @@ public function patternFilter(string $string) } #[AsTwigFunction(name: 'foo', isSafe: ['html'])] - public function fooFunction(string $string) + public function fooFunction(string|int $string) { } @@ -62,6 +62,11 @@ public function withContextFunction(array $context, string $string) { } + #[AsTwigFunction('no_context_function')] + public function noContextFunction($context) + { + } + #[AsTwigFunction('with_env_function')] public function withEnvFunction(Environment $env, string $string) { @@ -88,17 +93,17 @@ public function deprecatedFunction(string $string) } #[AsTwigTest(name: 'foo')] - public function fooTest(string $string) + public function fooTest(string|int $value) { } #[AsTwigTest('variadic_test')] - public function variadicTest(string ...$strings) + public function variadicTest(string ...$value) { } #[AsTwigTest('deprecated_test', deprecationInfo: new DeprecatedCallableInfo('foo/bar', '1.2'))] - public function deprecatedTest(string $string) + public function deprecatedTest($value, $argument) { } } diff --git a/tests/Extension/Fixtures/FilterWithoutValue.php b/tests/Extension/Fixtures/FilterWithoutValue.php new file mode 100644 index 00000000000..22f84e601ac --- /dev/null +++ b/tests/Extension/Fixtures/FilterWithoutValue.php @@ -0,0 +1,15 @@ +