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:
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