From cff6f1ca80603f19fc748996369c8b466db571e9 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 26 Sep 2024 17:30:58 +0200 Subject: [PATCH] Implement the `enum` function --- CHANGELOG | 1 + doc/functions/enum.rst | 26 +++++++++++ doc/functions/index.rst | 1 + src/Extension/CoreExtension.php | 26 +++++++++++ .../Expression/FunctionNode/EnumFunction.php | 45 +++++++++++++++++++ .../functions/enum/invalid_dynamic_enum.test | 13 ++++++ .../Fixtures/functions/enum/invalid_enum.test | 10 +++++ .../functions/enum/invalid_enum_escaping.test | 10 +++++ .../functions/enum/invalid_literal_type.test | 10 +++++ tests/Fixtures/functions/enum/valid.test | 30 +++++++++++++ 10 files changed, 172 insertions(+) create mode 100644 doc/functions/enum.rst create mode 100644 src/Node/Expression/FunctionNode/EnumFunction.php create mode 100644 tests/Fixtures/functions/enum/invalid_dynamic_enum.test create mode 100644 tests/Fixtures/functions/enum/invalid_enum.test create mode 100644 tests/Fixtures/functions/enum/invalid_enum_escaping.test create mode 100644 tests/Fixtures/functions/enum/invalid_literal_type.test create mode 100644 tests/Fixtures/functions/enum/valid.test diff --git a/CHANGELOG b/CHANGELOG index e1d01be3c..f2311502a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -15,6 +15,7 @@ * Support Markup instances (and any other \Stringable) as dynamic mapping keys * Deprecate the `sandbox` tag * Improve the way one can deprecate a Twig callable (use `deprecation_info` instead of the other callable options) + * Add the `enum` function # 3.14.0 (2024-09-09) diff --git a/doc/functions/enum.rst b/doc/functions/enum.rst new file mode 100644 index 000000000..3c9d1a7b4 --- /dev/null +++ b/doc/functions/enum.rst @@ -0,0 +1,26 @@ +``enum_cases`` +============== + +.. versionadded:: 3.15 + + The ``enum`` function was added in Twig 3.15. + +``enum`` gives access to enums: + +.. code-block:: twig + + {# display one specific case of a backed enum #} + {{ enum('App\\MyEnum').SomeCase.value }} + + {# get all cases of an enum #} + {% enum('App\\MyEnum').cases() %} + + {# call any methods of the enum class #} + {% enum('App\\MyEnum').someMethod() %} + +When using a string literal for the ``enum`` argument, it will be validated during compile time to be a valid enum name. + +Arguments +--------- + +* ``enum``: The FQCN of the enum diff --git a/doc/functions/index.rst b/doc/functions/index.rst index 27fd24383..557f6938a 100644 --- a/doc/functions/index.rst +++ b/doc/functions/index.rst @@ -10,6 +10,7 @@ Functions cycle date dump + enum enum_cases html_classes html_cva diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index 02613c5cd..66f08b5d1 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -50,6 +50,7 @@ use Twig\Node\Expression\Binary\SubBinary; use Twig\Node\Expression\BlockReferenceExpression; use Twig\Node\Expression\Filter\DefaultFilter; use Twig\Node\Expression\FunctionNode\EnumCasesFunction; +use Twig\Node\Expression\FunctionNode\EnumFunction; use Twig\Node\Expression\GetAttrExpression; use Twig\Node\Expression\NullCoalesceExpression; use Twig\Node\Expression\ParentExpression; @@ -264,6 +265,7 @@ final class CoreExtension extends AbstractExtension new TwigFunction('include', [self::class, 'include'], ['needs_environment' => true, 'needs_context' => true, 'is_safe' => ['all']]), new TwigFunction('source', [self::class, 'source'], ['needs_environment' => true, 'is_safe' => ['all']]), new TwigFunction('enum_cases', [self::class, 'enumCases'], ['node_class' => EnumCasesFunction::class]), + new TwigFunction('enum', [self::class, 'enum'], ['node_class' => EnumFunction::class]), ]; } @@ -1501,6 +1503,30 @@ final class CoreExtension extends AbstractExtension return $enum::cases(); } + /** + * Provides the ability to access enums by their class names. + * + * @template T of \UnitEnum + * + * @param class-string $enum + * + * @return T + * + * @internal + */ + public static function enum(string $enum): \UnitEnum + { + if (!enum_exists($enum)) { + throw new RuntimeError(sprintf('"%s" is not an enum.', $enum)); + } + + if (!$cases = $enum::cases()) { + throw new RuntimeError(sprintf('"%s" is an empty enum.', $enum)); + } + + return $cases[0]; + } + /** * Provides the ability to get constants from instances as well as class/global constants. * diff --git a/src/Node/Expression/FunctionNode/EnumFunction.php b/src/Node/Expression/FunctionNode/EnumFunction.php new file mode 100644 index 000000000..1f8b0ecf1 --- /dev/null +++ b/src/Node/Expression/FunctionNode/EnumFunction.php @@ -0,0 +1,45 @@ +getNode('arguments'); + if ($arguments->hasNode('enum')) { + $firstArgument = $arguments->getNode('enum'); + } elseif ($arguments->hasNode('0')) { + $firstArgument = $arguments->getNode('0'); + } else { + $firstArgument = null; + } + + if (!$firstArgument instanceof ConstantExpression || 1 !== \count($arguments)) { + parent::compile($compiler); + + return; + } + + $value = $firstArgument->getAttribute('value'); + + if (!\is_string($value)) { + throw new SyntaxError('The first argument of the "enum" function must be a string.', $this->getTemplateLine(), $this->getSourceContext()); + } + + if (!enum_exists($value)) { + throw new SyntaxError(\sprintf('The first argument of the "enum" function must be the name of an enum, "%s" given.', $value), $this->getTemplateLine(), $this->getSourceContext()); + } + + if (!$cases = $value::cases()) { + throw new SyntaxError(\sprintf('The first argument of the "enum" function must be a non-empty enum, "%s" given.', $value), $this->getTemplateLine(), $this->getSourceContext()); + } + + $compiler->raw(\sprintf('%s::%s', $value, $cases[0]->name)); + } +} diff --git a/tests/Fixtures/functions/enum/invalid_dynamic_enum.test b/tests/Fixtures/functions/enum/invalid_dynamic_enum.test new file mode 100644 index 000000000..1ae27ffe5 --- /dev/null +++ b/tests/Fixtures/functions/enum/invalid_dynamic_enum.test @@ -0,0 +1,13 @@ +--TEST-- +"enum" function with invalid dynamic enum class +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{% set from_variable = 'Twig\\Tests\\NonExistentEnum' %} +{% for c in enum(from_variable).cases() %} + {{~ c.name }} +{% endfor %} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\RuntimeError: "Twig\Tests\NonExistentEnum" is not an enum in "index.twig" at line 3. diff --git a/tests/Fixtures/functions/enum/invalid_enum.test b/tests/Fixtures/functions/enum/invalid_enum.test new file mode 100644 index 000000000..b38e7fc9a --- /dev/null +++ b/tests/Fixtures/functions/enum/invalid_enum.test @@ -0,0 +1,10 @@ +--TEST-- +"enum" function with invalid enum class +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{% for c in enum('Twig\\Tests\\NonExistentEnum').cases() %} + {{~ c.name }} +{% endfor %} +--EXCEPTION-- +Twig\Error\SyntaxError: The first argument of the "enum" function must be the name of an enum, "Twig\Tests\NonExistentEnum" given in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/enum/invalid_enum_escaping.test b/tests/Fixtures/functions/enum/invalid_enum_escaping.test new file mode 100644 index 000000000..5c10afb10 --- /dev/null +++ b/tests/Fixtures/functions/enum/invalid_enum_escaping.test @@ -0,0 +1,10 @@ +--TEST-- +"enum" function with missing \ escaping +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{% for c in enum('Twig\Tests\DummyBackedEnum').cases() %} + {{~ c.name }} +{% endfor %} +--EXCEPTION-- +Twig\Error\SyntaxError: The first argument of the "enum" function must be the name of an enum, "TwigTestsDummyBackedEnum" given in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/enum/invalid_literal_type.test b/tests/Fixtures/functions/enum/invalid_literal_type.test new file mode 100644 index 000000000..9b79dec06 --- /dev/null +++ b/tests/Fixtures/functions/enum/invalid_literal_type.test @@ -0,0 +1,10 @@ +--TEST-- +"enum" function with invalid literal type +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{% for c in enum(13).cases() %} + {{~ c.name }} +{% endfor %} +--EXCEPTION-- +Twig\Error\SyntaxError: The first argument of the "enum" function must be a string in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/enum/valid.test b/tests/Fixtures/functions/enum/valid.test new file mode 100644 index 000000000..8eb1a510f --- /dev/null +++ b/tests/Fixtures/functions/enum/valid.test @@ -0,0 +1,30 @@ +--TEST-- +"enum" function +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{{ enum('Twig\\Tests\\DummyBackedEnum').FOO.value }} +{% for c in enum('Twig\\Tests\\DummyBackedEnum').cases() %} + {{~ c.name }}: {{ c.value }} +{% endfor %} +{{ enum('Twig\\Tests\\DummyUnitEnum').BAR.name }} +{% for c in enum('Twig\\Tests\\DummyUnitEnum').cases() %} + {{~ c.name }} +{% endfor %} +{% set from_variable='Twig\\Tests\\DummyUnitEnum' %} +{{ enum(from_variable).BAR.name }} +{% for c in enum(from_variable).cases() %} + {{~ c.name }} +{% endfor %} +--DATA-- +return [] +--EXPECT-- +foo +FOO: foo +BAR: bar +BAR +BAR +BAZ +BAR +BAR +BAZ