Introduce a way to test Twig callables at compile time

This commit is contained in:
Fabien Potencier
2024-09-15 10:07:31 +02:00
parent 9b3528904e
commit 62f0a96b68
10 changed files with 226 additions and 0 deletions
+1
View File
@@ -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
+28
View File
@@ -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 %}
+1
View File
@@ -12,6 +12,7 @@ Tags
do
embed
extends
guard
flush
for
from
+10
View File
@@ -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()));
+2
View File
@@ -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(),
];
}
+22
View File
@@ -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();
+69
View File
@@ -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';
}
}
+21
View File
@@ -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
+12
View File
@@ -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.
+60
View File
@@ -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