Repository: twigphp/Twig (php)

Issue:
Support reading enums from PHP 8.1
Hi,

Since PHP 8.1 we can use Enums. Which in my opinion are a great asset to PHP.
But unfortunate there is not an elegant way of retrieving values inside Twig templates.

I currently use this in my Symfony project;

```twig
{{ constant('App\\...\\Enum::Key').value }}
```

Which will return the value of the key in de the Enum.

Maybe it's good to add some specific functions for this in the Twig core?

Regards, Bert

Pull request description:
Implement the `enum` function
Fixes #3681
Requires #4353

Patch:
diff --git a/CHANGELOG b/CHANGELOG
index e1d01be3..f2311502 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 00000000..3c9d1a7b
--- /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 27fd2438..557f6938 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 02613c5c..66f08b5d 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<T> $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 00000000..1f8b0ecf
--- /dev/null
+++ b/src/Node/Expression/FunctionNode/EnumFunction.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Twig\Node\Expression\FunctionNode;
+
+use Twig\Compiler;
+use Twig\Error\SyntaxError;
+use Twig\Node\Expression\ConstantExpression;
+use Twig\Node\Expression\FunctionExpression;
+
+class EnumFunction extends FunctionExpression
+{
+    public function compile(Compiler $compiler): void
+    {
+        $arguments = $this->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));
+    }
+}


Test patch:
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 00000000..1ae27ffe
--- /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 00000000..b38e7fc9
--- /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 00000000..5c10afb1
--- /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 00000000..9b79dec0
--- /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 00000000..8eb1a510
--- /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