Repository: twigphp/Twig (php)

Issue:
Make it easier to support undefined filters and functions?
### Problem

When using Twig in your own apps, you have full control over your dependencies, so all filters/functions are defined. However, when using Twig for third-party packages (e.g. Symfony bundles) this is not true.

E.g. in EasyAdmin bundle we use a Twig function called `csrf_token()` ... but that's only available if your app also has `symfony/security-csrf` package installed. This package is optional in EasyAdmin, so we can't use the function.

We ended up creating a `ea_call_function_if_exists(string $functionName, mixed ...$functionArguments)` Twig function:

```twig
{# instead of this: #}
{{ csrf_token('foo') }}

{# we must use this: #}
{{ ea_call_function_if_exists('csrf_token', 'foo') }}
```

This works well for some functions but not for all, because of their implementation.

### Solution

We could create a `try` tag like this:

```twig
{% try %} {{ csrf_token('foo') }} {% endtry %}

{% try %} {{ csrf_token('foo') }} {% else %} ... {% endtry %}
{# or #}
{% try %} {{ csrf_token('foo') }} {% catch %} ... {% endtry %}
```

Alternatively, we could make the well known `??` operator much smarter and allow using it in expressions like this:

```twig
{# if csrf_token() doesn't exist or throws an exception, '...'  is printed #}
{{ csrf_token('foo') ?? '...' }}

{# it would work for filters too #}
{{ 'foo'|csrf_token ?? '...' }}
```

Thanks!


Pull request description:
Add a new guard tag that allows to test if some Twig callables are available at compilation time
Closes #3655

~~@javiereguiluz Would that help?~~

~~Usage:~~

~~```php~~
~~$twig->registerFunctionStub('csrf_token', fn (string $tokenId) => '');~~
~~$twig->registerFunctionStub('importmap', fn (string|array $entryPoint = 'app', array $attributes = []): string => '');~~
~~$twig->registerTestStub('random', fn (string $value) => false);~~
~~```~~

See the docs on the new `guard` tag in the diff.


Patch:
diff --git a/CHANGELOG b/CHANGELOG
index 2a20109f..ef500e8a 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,6 @@
 # 3.15.0 (2024-XX-XX)
 
+ * Add a new `guard` tag that allows to test if some Twig callables are available at compilation time
  * Allow arrow functions everywhere
  * Deprecate passing a string or an array to Twig callable arguments accepting arrow functions (pass a `\Closure`)
  * Add support for triggering deprecations for future operator precedence changes
diff --git a/doc/tags/guard.rst b/doc/tags/guard.rst
new file mode 100644
index 00000000..8c9ef7cf
--- /dev/null
+++ b/doc/tags/guard.rst
@@ -0,0 +1,28 @@
+``guard``
+=========
+
+.. versionadded:: 3.13
+
+    The ``guard`` tag was added in Twig 3.15.
+
+The ``guard`` statement checks if some Twig callables are available at
+**compilation time** to bypass code compilation that would otherwise fail.
+
+.. code-block:: twig
+
+    {% guard function importmap %}
+        {{ importmap('app') }}
+    {% endguard %}
+
+The first argument is the Twig callable to test: ``filter``, ``function``, or
+``test``. The second argument is the Twig callable name you want to test.
+
+You can also generate different code if the callable does not exist:
+
+.. code-block:: twig
+
+    {% guard function importmap %}
+        {{ importmap('app') }}
+    {% else %}
+        {# the importmap function doesn't exist, generate fallback code #}
+    {% endguard %}
diff --git a/doc/tags/index.rst b/doc/tags/index.rst
index 692ca609..2c456630 100644
--- a/doc/tags/index.rst
+++ b/doc/tags/index.rst
@@ -12,6 +12,7 @@ Tags
     do
     embed
     extends
+    guard
     flush
     for
     from
diff --git a/src/ExpressionParser.php b/src/ExpressionParser.php
index 4e570ff7..dbfba20d 100644
--- a/src/ExpressionParser.php
+++ b/src/ExpressionParser.php
@@ -13,6 +13,7 @@
 namespace Twig;
 
 use Twig\Attribute\FirstClassTwigCallableReady;
+use Twig\Error\RuntimeError;
 use Twig\Error\SyntaxError;
 use Twig\Node\EmptyNode;
 use Twig\Node\Expression\AbstractExpression;
@@ -875,6 +876,9 @@ class ExpressionParser
         }
 
         if (!$test) {
+            if ($this->parser->shouldIgnoreUnknownTwigCallables()) {
+                return new TwigTest($name, fn () => '');
+            }
             $e = new SyntaxError(\sprintf('Unknown "%s" test.', $name), $line, $stream->getSourceContext());
             $e->addSuggestions($name, array_keys($this->env->getTests()));
 
@@ -893,6 +897,9 @@ class ExpressionParser
     private function getFunction(string $name, int $line): TwigFunction
     {
         if (!$function = $this->env->getFunction($name)) {
+            if ($this->parser->shouldIgnoreUnknownTwigCallables()) {
+                return new TwigFunction($name, fn () => '');
+            }
             $e = new SyntaxError(\sprintf('Unknown "%s" function.', $name), $line, $this->parser->getStream()->getSourceContext());
             $e->addSuggestions($name, array_keys($this->env->getFunctions()));
 
@@ -910,6 +917,9 @@ class ExpressionParser
     private function getFilter(string $name, int $line): TwigFilter
     {
         if (!$filter = $this->env->getFilter($name)) {
+            if ($this->parser->shouldIgnoreUnknownTwigCallables()) {
+                return new TwigFilter($name, fn () => '');
+            }
             $e = new SyntaxError(\sprintf('Unknown "%s" filter.', $name), $line, $this->parser->getStream()->getSourceContext());
             $e->addSuggestions($name, array_keys($this->env->getFilters()));
 
diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php
index 334ab408..21a67c21 100644
--- a/src/Extension/CoreExtension.php
+++ b/src/Extension/CoreExtension.php
@@ -81,6 +81,7 @@ use Twig\TokenParser\ExtendsTokenParser;
 use Twig\TokenParser\FlushTokenParser;
 use Twig\TokenParser\ForTokenParser;
 use Twig\TokenParser\FromTokenParser;
+use Twig\TokenParser\GuardTokenParser;
 use Twig\TokenParser\IfTokenParser;
 use Twig\TokenParser\ImportTokenParser;
 use Twig\TokenParser\IncludeTokenParser;
@@ -195,6 +196,7 @@ final class CoreExtension extends AbstractExtension
             new EmbedTokenParser(),
             new WithTokenParser(),
             new DeprecatedTokenParser(),
+            new GuardTokenParser(),
         ];
     }
 
diff --git a/src/Parser.php b/src/Parser.php
index d30868a0..05ad88ce 100644
--- a/src/Parser.php
+++ b/src/Parser.php
@@ -46,12 +46,18 @@ class Parser
     private $traits;
     private $embeddedTemplates = [];
     private $varNameSalt = 0;
+    private $ignoreUnknownTwigCallables = false;
 
     public function __construct(
         private Environment $env,
     ) {
     }
 
+    public function getEnvironment(): Environment
+    {
+        return $this->env;
+    }
+
     public function getVarName(): string
     {
         return \sprintf('__internal_parse_%d', $this->varNameSalt++);
@@ -116,6 +122,22 @@ class Parser
         return $node;
     }
 
+    public function shouldIgnoreUnknownTwigCallables(): bool
+    {
+        return $this->ignoreUnknownTwigCallables;
+    }
+
+    public function subparseIgnoreUnknownTwigCallables($test, bool $dropNeedle = false): void
+    {
+        $previous = $this->ignoreUnknownTwigCallables;
+        $this->ignoreUnknownTwigCallables = true;
+        try {
+            $this->subparse($test, $dropNeedle);
+        } finally {
+            $this->ignoreUnknownTwigCallables = $previous;
+        }
+    }
+
     public function subparse($test, bool $dropNeedle = false): Node
     {
         $lineno = $this->getCurrentToken()->getLine();
diff --git a/src/TokenParser/GuardTokenParser.php b/src/TokenParser/GuardTokenParser.php
new file mode 100644
index 00000000..17b221d4
--- /dev/null
+++ b/src/TokenParser/GuardTokenParser.php
@@ -0,0 +1,69 @@
+<?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\TokenParser;
+
+use Twig\Error\SyntaxError;
+use Twig\Node\EmptyNode;
+use Twig\Node\Node;
+use Twig\Node\Nodes;
+use Twig\Token;
+
+/**
+ * @internal
+ */
+final class GuardTokenParser extends AbstractTokenParser
+{
+    public function parse(Token $token): Node
+    {
+        $stream = $this->parser->getStream();
+        $typeToken = $stream->expect(Token::NAME_TYPE);
+        if (!in_array($typeToken->getValue(), ['function', 'filter', 'test'])) {
+            throw new SyntaxError(\sprintf('Supported guard types are function, filter and test, "%s" given.', $typeToken->getValue()), $typeToken->getLine(), $stream->getSourceContext());
+        }
+        $method = 'get'.$typeToken->getValue();
+
+        $nameToken = $stream->expect(Token::NAME_TYPE);
+
+        $exists = null !== $this->parser->getEnvironment()->$method($nameToken->getValue());
+
+        $stream->expect(Token::BLOCK_END_TYPE);
+        if ($exists) {
+            $body = $this->parser->subparse([$this, 'decideGuardFork']);
+        } else {
+            $body = new EmptyNode();
+            $this->parser->subparseIgnoreUnknownTwigCallables([$this, 'decideGuardFork']);
+        }
+        $else = new EmptyNode();
+        if ('else' === $stream->next()->getValue()) {
+            $stream->expect(Token::BLOCK_END_TYPE);
+            $else = $this->parser->subparse([$this, 'decideGuardEnd'], true);
+        }
+        $stream->expect(Token::BLOCK_END_TYPE);
+
+        return new Nodes([$exists ? $body : $else]);
+    }
+
+    public function decideGuardFork(Token $token): bool
+    {
+        return $token->test(['else', 'endguard']);
+    }
+
+    public function decideGuardEnd(Token $token): bool
+    {
+        return $token->test(['endguard']);
+    }
+
+    public function getTag(): string
+    {
+        return 'guard';
+    }
+}


Test patch:
diff --git a/tests/Fixtures/tags/guard/basic.test b/tests/Fixtures/tags/guard/basic.test
new file mode 100644
index 00000000..2c27a5ae
--- /dev/null
+++ b/tests/Fixtures/tags/guard/basic.test
@@ -0,0 +1,21 @@
+--TEST--
+"guard" creates a compilation time condition on Twig callables availability
+--TEMPLATE--
+{% guard filter foobar %}
+    NEVER
+    {{ 'a'|foobar }}
+{% else -%}
+    The foobar filter doesn't exist
+{% endguard %}
+
+{% guard function constant -%}
+    The constant function does exist
+{% else %}
+    NEVER
+{% endguard %}
+--DATA--
+return []
+--EXPECT--
+The foobar filter doesn't exist
+
+The constant function does exist
diff --git a/tests/Fixtures/tags/guard/exception.test b/tests/Fixtures/tags/guard/exception.test
new file mode 100644
index 00000000..644eccd5
--- /dev/null
+++ b/tests/Fixtures/tags/guard/exception.test
@@ -0,0 +1,12 @@
+--TEST--
+"guard" creates a compilation time condition on Twig callables availability
+--TEMPLATE--
+{% guard function foobar %}
+    {{ foobar() }}
+{% else %}
+    {{ foobar() }}
+{% endguard %}
+--DATA--
+return []
+--EXCEPTION--
+Twig\Error\SyntaxError: Unknown "foobar" function in "index.twig" at line 5.
diff --git a/tests/Fixtures/tags/guard/nested.test b/tests/Fixtures/tags/guard/nested.test
new file mode 100644
index 00000000..2918b141
--- /dev/null
+++ b/tests/Fixtures/tags/guard/nested.test
@@ -0,0 +1,60 @@
+--TEST--
+"guard" creates a compilation time condition on Twig callables availability
+--TEMPLATE--
+{% guard function constant %}
+    {% guard filter foobar %}
+        NEVER
+        {{ 'a'|foobar }}
+    {% else %}
+        The constant function does exist, but the foobar filter does not
+    {%- endguard %}
+{% else %}
+    NEVER
+{% endguard %}
+
+{% guard function constant -%}
+    {% guard filter upper -%}
+        The constant function does exist, and the upper filter as well
+    {%- else %}
+        NEVER
+    {% endguard %}
+{% else %}
+    NEVER
+{% endguard %}
+
+{% guard filter foobar %}
+    NEVER
+    {{ 'a'|foobar }}
+{% else -%}
+    {% guard function barfoo %}
+        NEVER
+    {% else -%}
+        The foobar filter does not exist, and the barfoo function does not exist
+    {%- endguard %}
+{% endguard %}
+
+{% guard filter foobar %}
+    NEVER
+    {{ 'a'|foobar }}
+{% else %}
+    {%- guard function constant -%}
+        The foobar filter does not exist, but the constant function exists
+    {% else -%}
+        NEVER
+    {% endguard %}
+{% endguard %}
+
+{% guard function first %}
+    {% guard function second %}
+        NEVER
+        {{ second() }}
+    {% endguard %}
+    {{ first() }}
+{% endguard %}
+--DATA--
+return []
+--EXPECT--
+The constant function does exist, but the foobar filter does not
+The constant function does exist, and the upper filter as well
+The foobar filter does not exist, and the barfoo function does not exist
+The foobar filter does not exist, but the constant function exists