Add needs_charset option for filters and functions

This commit is contained in:
Fabien Potencier
2024-05-01 12:58:33 +02:00
parent a19ec5b200
commit c63f695e8a
11 changed files with 93 additions and 76 deletions
+1
View File
@@ -1,5 +1,6 @@
# 3.10.0 (2024-XX-XX)
* Add `needs_charset` option for filters and functions
* Extract the escaping logic from the `EscapingExtension` class to a new
`EscapingRuntime` class.
+11
View File
@@ -175,6 +175,17 @@ The ``\Twig\TwigFilter`` class takes an array of options as its last argument::
$filter = new \Twig\TwigFilter('rot13', 'str_rot13', $options);
Charset-aware Filters
~~~~~~~~~~~~~~~~~~~~~
If you want to access the default charset in your filter, set the
``needs_charset`` option to ``true``; Twig will pass the default charset as the
first argument to the filter call::
$filter = new \Twig\TwigFilter('rot13', function (string $charset, $string) {
return str_rot13($string);
}, ['needs_charset' => true]);
Environment-aware Filters
~~~~~~~~~~~~~~~~~~~~~~~~~
+32 -38
View File
@@ -199,10 +199,10 @@ final class CoreExtension extends AbstractExtension
new TwigFilter('convert_encoding', [self::class, 'convertEncoding']),
// string filters
new TwigFilter('title', [self::class, 'titleCase'], ['needs_environment' => true]),
new TwigFilter('capitalize', [self::class, 'capitalize'], ['needs_environment' => true]),
new TwigFilter('upper', [self::class, 'upper'], ['needs_environment' => true]),
new TwigFilter('lower', [self::class, 'lower'], ['needs_environment' => true]),
new TwigFilter('title', [self::class, 'titleCase'], ['needs_charset' => true]),
new TwigFilter('capitalize', [self::class, 'capitalize'], ['needs_charset' => true]),
new TwigFilter('upper', [self::class, 'upper'], ['needs_charset' => true]),
new TwigFilter('lower', [self::class, 'lower'], ['needs_charset' => true]),
new TwigFilter('striptags', [self::class, 'striptags']),
new TwigFilter('trim', [self::class, 'trim']),
new TwigFilter('nl2br', [self::class, 'nl2br'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
@@ -210,7 +210,7 @@ final class CoreExtension extends AbstractExtension
// array helpers
new TwigFilter('join', [self::class, 'join']),
new TwigFilter('split', [self::class, 'split'], ['needs_environment' => true]),
new TwigFilter('split', [self::class, 'split'], ['needs_charset' => true]),
new TwigFilter('sort', [self::class, 'sort'], ['needs_environment' => true]),
new TwigFilter('merge', [self::class, 'merge']),
new TwigFilter('batch', [self::class, 'batch']),
@@ -220,11 +220,11 @@ final class CoreExtension extends AbstractExtension
new TwigFilter('reduce', [self::class, 'reduce'], ['needs_environment' => true]),
// string/array filters
new TwigFilter('reverse', [self::class, 'reverse'], ['needs_environment' => true]),
new TwigFilter('length', [self::class, 'length'], ['needs_environment' => true]),
new TwigFilter('slice', [self::class, 'slice'], ['needs_environment' => true]),
new TwigFilter('first', [self::class, 'first'], ['needs_environment' => true]),
new TwigFilter('last', [self::class, 'last'], ['needs_environment' => true]),
new TwigFilter('reverse', [self::class, 'reverse'], ['needs_charset' => true]),
new TwigFilter('length', [self::class, 'length'], ['needs_charset' => true]),
new TwigFilter('slice', [self::class, 'slice'], ['needs_charset' => true]),
new TwigFilter('first', [self::class, 'first'], ['needs_charset' => true]),
new TwigFilter('last', [self::class, 'last'], ['needs_charset' => true]),
// iteration and runtime
new TwigFilter('default', [self::class, 'default'], ['node_class' => DefaultFilter::class]),
@@ -240,7 +240,7 @@ final class CoreExtension extends AbstractExtension
new TwigFunction('range', 'range'),
new TwigFunction('constant', [self::class, 'constant']),
new TwigFunction('cycle', [self::class, 'cycle']),
new TwigFunction('random', [self::class, 'random'], ['needs_environment' => true]),
new TwigFunction('random', [self::class, 'random'], ['needs_charset' => true]),
new TwigFunction('date', [self::class, 'convertDate'], ['needs_environment' => true]),
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']]),
@@ -350,7 +350,7 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function random(Environment $env, $values = null, $max = null)
public static function random(string $charset, $values = null, $max = null)
{
if (null === $values) {
return null === $max ? mt_rand() : mt_rand(0, (int) $max);
@@ -377,8 +377,6 @@ final class CoreExtension extends AbstractExtension
return '';
}
$charset = $env->getCharset();
if ('UTF-8' !== $charset) {
$values = self::convertEncoding($values, 'UTF-8', $charset);
}
@@ -654,7 +652,7 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function slice(Environment $env, $item, $start, $length = null, $preserveKeys = false)
public static function slice(string $charset, $item, $start, $length = null, $preserveKeys = false)
{
if ($item instanceof \Traversable) {
while ($item instanceof \IteratorAggregate) {
@@ -676,7 +674,7 @@ final class CoreExtension extends AbstractExtension
return \array_slice($item, $start, $length, $preserveKeys);
}
return mb_substr((string) $item, $start, $length, $env->getCharset());
return mb_substr((string) $item, $start, $length, $charset);
}
/**
@@ -688,9 +686,9 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function first(Environment $env, $item)
public static function first(string $charset, $item)
{
$elements = self::slice($env, $item, 0, 1, false);
$elements = self::slice($charset, $item, 0, 1, false);
return \is_string($elements) ? $elements : current($elements);
}
@@ -704,9 +702,9 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function last(Environment $env, $item)
public static function last(string $charset, $item)
{
$elements = self::slice($env, $item, -1, 1, false);
$elements = self::slice($charset, $item, -1, 1, false);
return \is_string($elements) ? $elements : current($elements);
}
@@ -775,7 +773,7 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function split(Environment $env, $value, $delimiter, $limit = null): array
public static function split(string $charset, $value, $delimiter, $limit = null): array
{
$value = $value ?? '';
@@ -787,14 +785,14 @@ final class CoreExtension extends AbstractExtension
return preg_split('/(?<!^)(?!$)/u', $value);
}
$length = mb_strlen($value, $env->getCharset());
$length = mb_strlen($value, $charset);
if ($length < $limit) {
return [$value];
}
$r = [];
for ($i = 0; $i < $length; $i += $limit) {
$r[] = mb_substr($value, $i, $limit, $env->getCharset());
$r[] = mb_substr($value, $i, $limit, $charset);
}
return $r;
@@ -868,7 +866,7 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function reverse(Environment $env, $item, $preserveKeys = false)
public static function reverse(string $charset, $item, $preserveKeys = false)
{
if ($item instanceof \Traversable) {
return array_reverse(iterator_to_array($item), $preserveKeys);
@@ -880,8 +878,6 @@ final class CoreExtension extends AbstractExtension
$string = (string) $item;
$charset = $env->getCharset();
if ('UTF-8' !== $charset) {
$string = self::convertEncoding($string, 'UTF-8', $charset);
}
@@ -1125,14 +1121,14 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function length(Environment $env, $thing): int
public static function length(string $charset, $thing): int
{
if (null === $thing) {
return 0;
}
if (\is_scalar($thing)) {
return mb_strlen($thing, $env->getCharset());
return mb_strlen($thing, $charset);
}
if ($thing instanceof \Countable || \is_array($thing) || $thing instanceof \SimpleXMLElement) {
@@ -1144,7 +1140,7 @@ final class CoreExtension extends AbstractExtension
}
if (method_exists($thing, '__toString')) {
return mb_strlen((string) $thing, $env->getCharset());
return mb_strlen((string) $thing, $charset);
}
return 1;
@@ -1157,9 +1153,9 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function upper(Environment $env, $string): string
public static function upper(string $charset, $string): string
{
return mb_strtoupper($string ?? '', $env->getCharset());
return mb_strtoupper($string ?? '', $charset);
}
/**
@@ -1169,9 +1165,9 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function lower(Environment $env, $string): string
public static function lower(string $charset, $string): string
{
return mb_strtolower($string ?? '', $env->getCharset());
return mb_strtolower($string ?? '', $charset);
}
/**
@@ -1194,9 +1190,9 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function titleCase(Environment $env, $string): string
public static function titleCase(string $charset, $string): string
{
return mb_convert_case($string ?? '', \MB_CASE_TITLE, $env->getCharset());
return mb_convert_case($string ?? '', \MB_CASE_TITLE, $charset);
}
/**
@@ -1206,10 +1202,8 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function capitalize(Environment $env, $string): string
public static function capitalize(string $charset, $string): string
{
$charset = $env->getCharset();
return mb_strtoupper(mb_substr($string ?? '', 0, 1, $charset), $charset).mb_strtolower(mb_substr($string ?? '', 1, null, $charset), $charset);
}
+11
View File
@@ -61,7 +61,15 @@ abstract class CallExpression extends AbstractExpression
$first = true;
if ($this->hasAttribute('needs_charset') && $this->getAttribute('needs_charset')) {
$compiler->raw('$this->env->getCharset()');
$first = false;
}
if ($this->hasAttribute('needs_environment') && $this->getAttribute('needs_environment')) {
if (!$first) {
$compiler->raw(', ');
}
$compiler->raw('$this->env');
$first = false;
}
@@ -245,6 +253,9 @@ abstract class CallExpression extends AbstractExpression
if ($this->hasNode('node')) {
array_shift($parameters);
}
if ($this->hasAttribute('needs_charset') && $this->getAttribute('needs_charset')) {
array_shift($parameters);
}
if ($this->hasAttribute('needs_environment') && $this->getAttribute('needs_environment')) {
array_shift($parameters);
}
+1
View File
@@ -29,6 +29,7 @@ class FilterExpression extends CallExpression
$this->setAttribute('name', $name);
$this->setAttribute('type', 'filter');
$this->setAttribute('needs_charset', $filter->needsCharset());
$this->setAttribute('needs_environment', $filter->needsEnvironment());
$this->setAttribute('needs_context', $filter->needsContext());
$this->setAttribute('arguments', $filter->getArguments());
@@ -29,6 +29,7 @@ class FunctionExpression extends CallExpression
$this->setAttribute('name', $name);
$this->setAttribute('type', 'function');
$this->setAttribute('needs_charset', $function->needsCharset());
$this->setAttribute('needs_environment', $function->needsEnvironment());
$this->setAttribute('needs_context', $function->needsContext());
$this->setAttribute('arguments', $function->getArguments());
+11 -11
View File
@@ -31,7 +31,7 @@ function twig_random(Environment $env, $values = null, $max = null)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::random($env, $values, $max);
return CoreExtension::random($env->getCharset(), $values, $max);
}
/**
@@ -141,7 +141,7 @@ function twig_slice(Environment $env, $item, $start, $length = null, $preserveKe
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::slice($env, $item, $start, $length, $preserveKeys);
return CoreExtension::slice($env->getCharset(), $item, $start, $length, $preserveKeys);
}
/**
@@ -152,7 +152,7 @@ function twig_first(Environment $env, $item)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::first($env, $item);
return CoreExtension::first($env->getCharset(), $item);
}
/**
@@ -163,7 +163,7 @@ function twig_last(Environment $env, $item)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::last($env, $item);
return CoreExtension::last($env->getCharset(), $item);
}
/**
@@ -185,7 +185,7 @@ function twig_split_filter(Environment $env, $value, $delimiter, $limit = null)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::split($env, $value, $delimiter, $limit);
return CoreExtension::split($env->getCharset(), $value, $delimiter, $limit);
}
/**
@@ -207,7 +207,7 @@ function twig_reverse_filter(Environment $env, $item, $preserveKeys = false)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::reverse($env, $item, $preserveKeys);
return CoreExtension::reverse($env->getCharset(), $item, $preserveKeys);
}
/**
@@ -284,7 +284,7 @@ function twig_length_filter(Environment $env, $thing)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::length($env, $thing);
return CoreExtension::length($env->getCharset(), $thing);
}
/**
@@ -295,7 +295,7 @@ function twig_upper_filter(Environment $env, $string)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::upper($env, $string);
return CoreExtension::upper($env->getCharset(), $string);
}
/**
@@ -306,7 +306,7 @@ function twig_lower_filter(Environment $env, $string)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::lower($env, $string);
return CoreExtension::lower($env->getCharset(), $string);
}
/**
@@ -328,7 +328,7 @@ function twig_title_string_filter(Environment $env, $string)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::titleCase($env, $string);
return CoreExtension::titleCase($env->getCharset(), $string);
}
/**
@@ -339,7 +339,7 @@ function twig_capitalize_string_filter(Environment $env, $string)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::capitalize($env, $string);
return CoreExtension::capitalize($env->getCharset(), $string);
}
/**
+6
View File
@@ -38,6 +38,7 @@ final class TwigFilter
$this->options = array_merge([
'needs_environment' => false,
'needs_context' => false,
'needs_charset' => false,
'is_variadic' => false,
'is_safe' => null,
'is_safe_callback' => null,
@@ -79,6 +80,11 @@ final class TwigFilter
return $this->arguments;
}
public function needsCharset(): bool
{
return $this->options['needs_charset'];
}
public function needsEnvironment(): bool
{
return $this->options['needs_environment'];
+6
View File
@@ -38,6 +38,7 @@ final class TwigFunction
$this->options = array_merge([
'needs_environment' => false,
'needs_context' => false,
'needs_charset' => false,
'is_variadic' => false,
'is_safe' => null,
'is_safe_callback' => null,
@@ -77,6 +78,11 @@ final class TwigFunction
return $this->arguments;
}
public function needsCharset(): bool
{
return $this->options['needs_charset'];
}
public function needsEnvironment(): bool
{
return $this->options['needs_environment'];
+10 -24
View File
@@ -12,10 +12,8 @@ namespace Twig\Tests\Extension;
*/
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Extension\CoreExtension;
use Twig\Loader\LoaderInterface;
class CoreTest extends TestCase
{
@@ -24,10 +22,8 @@ class CoreTest extends TestCase
*/
public function testRandomFunction(array $expectedInArray, $value1, $value2 = null)
{
$env = new Environment($this->createMock(LoaderInterface::class));
for ($i = 0; $i < 100; ++$i) {
$this->assertTrue(\in_array(CoreExtension::random($env, $value1, $value2), $expectedInArray, true)); // assertContains() would not consider the type
$this->assertTrue(\in_array(CoreExtension::random('UTF-8', $value1, $value2), $expectedInArray, true)); // assertContains() would not consider the type
}
}
@@ -85,45 +81,38 @@ class CoreTest extends TestCase
$max = mt_getrandmax();
for ($i = 0; $i < 100; ++$i) {
$val = CoreExtension::random(new Environment($this->createMock(LoaderInterface::class)));
$val = CoreExtension::random('UTF-8');
$this->assertTrue(\is_int($val) && $val >= 0 && $val <= $max);
}
}
public function testRandomFunctionReturnsAsIs()
{
$this->assertSame('', CoreExtension::random(new Environment($this->createMock(LoaderInterface::class)), ''));
$this->assertSame('', CoreExtension::random(new Environment($this->createMock(LoaderInterface::class), ['charset' => null]), ''));
$this->assertSame('', CoreExtension::random('UTF-8', ''));
$instance = new \stdClass();
$this->assertSame($instance, CoreExtension::random(new Environment($this->createMock(LoaderInterface::class)), $instance));
$this->assertSame($instance, CoreExtension::random('UTF-8', $instance));
}
public function testRandomFunctionOfEmptyArrayThrowsException()
{
$this->expectException(RuntimeError::class);
CoreExtension::random(new Environment($this->createMock(LoaderInterface::class)), []);
CoreExtension::random('UTF-8', []);
}
public function testRandomFunctionOnNonUTF8String()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
$twig->setCharset('ISO-8859-1');
$text = iconv('UTF-8', 'ISO-8859-1', 'Äé');
for ($i = 0; $i < 30; ++$i) {
$rand = CoreExtension::random($twig, $text);
$rand = CoreExtension::random('ISO-8859-1', $text);
$this->assertTrue(\in_array(iconv('ISO-8859-1', 'UTF-8', $rand), ['Ä', 'é'], true));
}
}
public function testReverseFilterOnNonUTF8String()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
$twig->setCharset('ISO-8859-1');
$input = iconv('UTF-8', 'ISO-8859-1', 'Äé');
$output = iconv('ISO-8859-1', 'UTF-8', CoreExtension::reverse($twig, $input));
$output = iconv('ISO-8859-1', 'UTF-8', CoreExtension::reverse('ISO-8859-1', $input));
$this->assertEquals($output, 'éÄ');
}
@@ -133,8 +122,7 @@ class CoreTest extends TestCase
*/
public function testTwigFirst($expected, $input)
{
$twig = new Environment($this->createMock(LoaderInterface::class));
$this->assertSame($expected, CoreExtension::first($twig, $input));
$this->assertSame($expected, CoreExtension::first('UTF-8', $input));
}
public function provideTwigFirstCases()
@@ -155,8 +143,7 @@ class CoreTest extends TestCase
*/
public function testTwigLast($expected, $input)
{
$twig = new Environment($this->createMock(LoaderInterface::class));
$this->assertSame($expected, CoreExtension::last($twig, $input));
$this->assertSame($expected, CoreExtension::last('UTF-8', $input));
}
public function provideTwigLastCases()
@@ -228,8 +215,7 @@ class CoreTest extends TestCase
*/
public function testSliceFilter($expected, $input, $start, $length = null, $preserveKeys = false)
{
$twig = new Environment($this->createMock(LoaderInterface::class));
$this->assertSame($expected, CoreExtension::slice($twig, $input, $start, $length, $preserveKeys));
$this->assertSame($expected, CoreExtension::slice('UTF-8', $input, $start, $length, $preserveKeys));
}
public function provideSliceFilterCases()
+3 -3
View File
@@ -69,7 +69,7 @@ class FilterTest extends NodeTestCase
$node = $this->createFilter($expr, 'upper');
$node = $this->createFilter($node, 'number_format', [new ConstantExpression(2, 1), new ConstantExpression('.', 1), new ConstantExpression(',', 1)]);
$tests[] = [$node, 'Twig\Extension\CoreExtension::formatNumber($this->env, Twig\Extension\CoreExtension::upper($this->env, "foo"), 2, ".", ",")'];
$tests[] = [$node, 'Twig\Extension\CoreExtension::formatNumber($this->env, Twig\Extension\CoreExtension::upper($this->env->getCharset(), "foo"), 2, ".", ",")'];
// named arguments
$date = new ConstantExpression(0, 1);
@@ -91,11 +91,11 @@ class FilterTest extends NodeTestCase
$node = $this->createFilter($string, 'reverse', [
'preserve_keys' => new ConstantExpression(true, 1),
]);
$tests[] = [$node, 'Twig\Extension\CoreExtension::reverse($this->env, "abc", true)'];
$tests[] = [$node, 'Twig\Extension\CoreExtension::reverse($this->env->getCharset(), "abc", true)'];
$node = $this->createFilter($string, 'reverse', [
'preserveKeys' => new ConstantExpression(true, 1),
]);
$tests[] = [$node, 'Twig\Extension\CoreExtension::reverse($this->env, "abc", true)'];
$tests[] = [$node, 'Twig\Extension\CoreExtension::reverse($this->env->getCharset(), "abc", true)'];
// filter as an anonymous function
$node = $this->createFilter(new ConstantExpression('foo', 1), 'anonymous');