diff --git a/CHANGELOG b/CHANGELOG index 2a20109f6..ef500e8aa 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 000000000..8c9ef7cfe --- /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 692ca6094..2c4566309 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 4e570ff76..dbfba20d4 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 334ab4084..21a67c217 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 d30868a06..05ad88ce8 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 000000000..17b221d4c --- /dev/null +++ b/src/TokenParser/GuardTokenParser.php @@ -0,0 +1,69 @@ +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'; + } +} diff --git a/tests/Fixtures/tags/guard/basic.test b/tests/Fixtures/tags/guard/basic.test new file mode 100644 index 000000000..2c27a5ae7 --- /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 000000000..644eccd54 --- /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 000000000..2918b1416 --- /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