Introduce registerUndefinedTestCallback

This commit is contained in:
Vincent Langlet
2025-08-29 20:34:23 +02:00
committed by Fabien Potencier
parent fecc10cb14
commit cc12df995c
9 changed files with 122 additions and 9 deletions
+1
View File
@@ -1,6 +1,7 @@
# 3.22.0 (2025-XX-XX)
* Add support for two words test in guard tag
* Add `Environment::registerUndefinedTestCallback()`
# 3.21.1 (2025-05-03)
+11 -5
View File
@@ -271,13 +271,19 @@ Defining undefined Functions, Filters, and Tags on the Fly
The ``registerUndefinedTokenParserCallback()`` method was added in Twig
3.2.
When a function/filter/tag is not defined, Twig defaults to throw a
.. versionadded:: 3.22
The ``registerUndefinedTestCallback()`` method was added in Twig
3.22.
When a function/filter/test/tag is not defined, Twig defaults to throw a
``\Twig\Error\SyntaxError`` exception. However, it can also call a `callback`_
(any valid PHP callable) which should return a function/filter/tag.
(any valid PHP callable) which should return a function/filter/test/tag.
For tags, register callbacks with ``registerUndefinedTokenParserCallback()``.
For filters, register callbacks with ``registerUndefinedFilterCallback()``.
For functions, use ``registerUndefinedFunctionCallback()``::
For functions, use ``registerUndefinedFunctionCallback()``.
For tests, use ``registerUndefinedTestCallback()``::
// auto-register all native PHP functions as Twig functions
// NEVER do this in a project as it's NOT secure
@@ -289,7 +295,7 @@ For functions, use ``registerUndefinedFunctionCallback()``::
return false;
});
If the callable is not able to return a valid function/filter/tag, it must
If the callable is not able to return a valid function/filter/test/tag, it must
return ``false``.
If you register more than one callback, Twig will call them in turn until one
@@ -297,7 +303,7 @@ does not return ``false``.
.. tip::
As the resolution of functions/filters/tags is done during compilation,
As the resolution of functions/filters/tests/tags is done during compilation,
there is no overhead when registering these callbacks.
.. warning::
+8
View File
@@ -827,6 +827,14 @@ class Environment
return $this->extensionSet->getTest($name);
}
/**
* @param callable(string): (TwigTest|false) $callable
*/
public function registerUndefinedTestCallback(callable $callable): void
{
$this->extensionSet->registerUndefinedTestCallback($callable);
}
/**
* @return void
*/
+16
View File
@@ -59,6 +59,8 @@ final class ExtensionSet
private $functionCallbacks = [];
/** @var array<callable(string): (TwigFilter|false)> */
private $filterCallbacks = [];
/** @var array<callable(string): (TwigTest|false)> */
private $testCallbacks = [];
/** @var array<callable(string): (TokenParserInterface|false)> */
private $parserCallbacks = [];
private $lastModified = 0;
@@ -410,9 +412,23 @@ final class ExtensionSet
}
}
foreach ($this->testCallbacks as $callback) {
if (false !== $test = $callback($name)) {
return $test;
}
}
return null;
}
/**
* @param callable(string): (TwigTest|false) $callable
*/
public function registerUndefinedTestCallback(callable $callable): void
{
$this->testCallbacks[] = $callable;
}
public function getExpressionParsers(): ExpressionParsers
{
if (!$this->initialized) {
+18 -3
View File
@@ -494,11 +494,26 @@ class Parser
// try 2-words tests
$name = $name.' '.$this->getCurrentToken()->getValue();
if ($test = $this->env->getTest($name)) {
$this->stream->next();
try {
$test = $this->env->getTest($name);
} catch (SyntaxError $e) {
if (!$this->shouldIgnoreUnknownTwigCallables()) {
throw $e;
}
$test = null;
}
$this->stream->next();
} else {
$test = $this->env->getTest($name);
try {
$test = $this->env->getTest($name);
} catch (SyntaxError $e) {
if (!$this->shouldIgnoreUnknownTwigCallables()) {
throw $e;
}
$test = null;
}
}
if (!$test) {
+12
View File
@@ -101,6 +101,14 @@ abstract class IntegrationTestCase extends TestCase
return [];
}
/**
* @return array<callable(string): (TwigTest|false)>
*/
protected function getUndefinedTestCallbacks(): array
{
return [];
}
/**
* @return array<callable(string): (TokenParserInterface|false)>
*/
@@ -255,6 +263,10 @@ abstract class IntegrationTestCase extends TestCase
$twig->registerUndefinedFunctionCallback($callback);
}
foreach ($this->getUndefinedTestCallbacks() as $callback) {
$twig->registerUndefinedTestCallback($callback);
}
foreach ($this->getUndefinedTokenParserCallbacks() as $callback) {
$twig->registerUndefinedTokenParserCallback($callback);
}
+16
View File
@@ -425,6 +425,22 @@ class EnvironmentTest extends TestCase
$this->assertSame('dynamic', $filter->getName());
}
public function testUndefinedTestCallback()
{
$twig = new Environment(new ArrayLoader());
$twig->registerUndefinedTestCallback(function (string $name) {
if ('dynamic' === $name) {
return new TwigTest('dynamic', function () { return 'dynamic'; });
}
return false;
});
$this->assertNull($twig->getTest('does_not_exist'));
$this->assertInstanceOf(TwigTest::class, $test = $twig->getTest('dynamic'));
$this->assertSame('dynamic', $test->getName());
}
public function testUndefinedTokenParserCallback()
{
$twig = new Environment(new ArrayLoader());
@@ -14,9 +14,27 @@
{% else -%}
The throwing_undefined_function function doesn't exist
{% endguard %}
{% guard test throwing_undefined_test -%}
NEVER
{% if 'a' is throwing_undefined_test('b') %}{% endif %}
{% else -%}
The throwing_undefined_test test doesn't exist
{% endguard %}
{% guard test throwing_undefined_two words_test -%}
NEVER
{% if 'a' is throwing_undefined_test words_test('b') %}{% endif %}
{% else -%}
The throwing_undefined_two words_test test doesn't exist
{% endguard %}
--DATA--
return []
--EXPECT--
The throwing_undefined_filter filter doesn't exist
The throwing_undefined_function function doesn't exist
The throwing_undefined_test test doesn't exist
The throwing_undefined_two words_test test doesn't exist
+22 -1
View File
@@ -72,7 +72,28 @@ class IntegrationTest extends IntegrationTestCase
];
}
protected function getUndefinedTokenParserCallbacks(): array
protected function getUndefinedTestCallbacks(): array
{
return [
static function (string $name) {
if ('throwing_undefined_test' === $name) {
throw new SyntaxError('This test is undefined in the tests.');
}
if ('throwing_undefined_two words_test' === $name) {
throw new SyntaxError('This test is undefined in the tests.');
}
// Ensure this does not conflict with `divisible by` and `same as`.
if (\in_array($name, ['divisible', 'same'], true)) {
return new TwigTest($name, fn () => '');
}
return false;
},
];
}
protected function getUndefinedFilterCallbacks(): array
{
return [
static function (string $name) {