diff --git a/doc/filters/escape.rst b/doc/filters/escape.rst index 4a597c9bc..c5ea3ccbf 100644 --- a/doc/filters/escape.rst +++ b/doc/filters/escape.rst @@ -39,7 +39,8 @@ And here is how to escape variables included in JavaScript code: The ``escape`` filter supports the following escaping strategies for HTML documents: -* ``html``: escapes a string for the **HTML body** context. +* ``html``: escapes a string for the **HTML body** context, + or for HTML attributes values **inside quotes**. * ``js``: escapes a string for the **JavaScript** context. @@ -50,7 +51,8 @@ documents: * ``url``: escapes a string for the **URI or parameter** contexts. This should not be used to escape an entire URI; only a subcomponent being inserted. -* ``html_attr``: escapes a string for the **HTML attribute** context. +* ``html_attr``: escapes a string for the **HTML attribute** context, + **without quotes** around HTML attribute values. Note that doing contextual escaping in HTML documents is hard and choosing the right escaping strategy depends on a lot of factors. Please, read related @@ -90,6 +92,27 @@ to learn more about this topic. {{ var|escape(strategy)|raw }} {# won't be double-escaped #} {% endautoescape %} +.. tip:: + + The ``html_attr`` escaping strategy can be useful when you need to + escape a **dynamic HTML attribute name**: + + .. code-block:: html+twig + +
+ + It can also be used for escaping a **dynamic HTML attribute value** + if it is not quoted, but this is **less performant**. + Instead, it is recommended to quote the HTML attribute value and use + the ``html`` escaping strategy: + + .. code-block:: html+twig + +
+ + {# is equivalent to, but is less performant #} +
+ Custom Escapers --------------- diff --git a/extra/intl-extra/Tests/Fixtures/currency_names.test b/extra/intl-extra/Tests/Fixtures/currency_names.test index bc2c54d02..7834d721a 100644 --- a/extra/intl-extra/Tests/Fixtures/currency_names.test +++ b/extra/intl-extra/Tests/Fixtures/currency_names.test @@ -10,7 +10,7 @@ return []; --EXPECT-- 0 -292 -292 +293 +293 US Dollar dollar des États-Unis diff --git a/src/AbstractTwigCallable.php b/src/AbstractTwigCallable.php index d2c2ef7ff..9af36709e 100644 --- a/src/AbstractTwigCallable.php +++ b/src/AbstractTwigCallable.php @@ -92,6 +92,9 @@ abstract class AbstractTwigCallable implements TwigCallableInterface return $this->options['needs_context']; } + /** + * @return static + */ public function withDynamicArguments(string $name, string $dynamicName, array $arguments): self { $new = clone $this; diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index 4186a0a76..4c4eff3e1 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -442,9 +442,9 @@ final class CoreExtension extends AbstractExtension * * {{ post.published_at|date("m/d/Y") }} * - * @param \DateTimeInterface|\DateInterval|string $date A date - * @param string|null $format The target format, null to use the default - * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged + * @param \DateTimeInterface|\DateInterval|string|int|null $date A date, a timestamp or null to use the current time + * @param string|null $format The target format, null to use the default + * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged */ public function formatDate($date, $format = null, $timezone = null): string { @@ -465,8 +465,8 @@ final class CoreExtension extends AbstractExtension * * {{ post.published_at|date_modify("-1day")|date("m/d/Y") }} * - * @param \DateTimeInterface|string $date A date - * @param string $modifier A modifier string + * @param \DateTimeInterface|string|int|null $date A date, a timestamp or null to use the current time + * @param string $modifier A modifier string * * @internal */ @@ -503,8 +503,8 @@ final class CoreExtension extends AbstractExtension * {# do something #} * {% endif %} * - * @param \DateTimeInterface|string|null $date A date or null to use the current time - * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged + * @param \DateTimeInterface|string|int|null $date A date, a timestamp or null to use the current time + * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged */ public function convertDate($date = null, $timezone = null): \DateTimeImmutable { diff --git a/src/Util/CallableArgumentsExtractor.php b/src/Util/CallableArgumentsExtractor.php index eaa445328..9b3bf90ce 100644 --- a/src/Util/CallableArgumentsExtractor.php +++ b/src/Util/CallableArgumentsExtractor.php @@ -40,22 +40,25 @@ final class CallableArgumentsExtractor public function extractArguments(Node $arguments): array { $extractedArguments = []; + $extractedArgumentNameMap = []; $named = false; foreach ($arguments as $name => $node) { if (!\is_int($name)) { $named = true; - $name = $this->normalizeName($name); } elseif ($named) { throw new SyntaxError(\sprintf('Positional arguments cannot be used after named arguments for %s "%s".', $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext()); } - $extractedArguments[$name] = $node; + $extractedArguments[$normalizedName = $this->normalizeName($name)] = $node; + $extractedArgumentNameMap[$normalizedName] = $name; } if (!$named && !$this->twigCallable->isVariadic()) { $min = $this->twigCallable->getMinimalNumberOfRequiredArguments(); if (\count($extractedArguments) < $this->rc->getReflector()->getNumberOfRequiredParameters() - $min) { - throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $this->rc->getReflector()->getParameters()[$min + \count($extractedArguments)]->getName(), $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext()); + $argName = $this->toSnakeCase($this->rc->getReflector()->getParameters()[$min + \count($extractedArguments)]->getName()); + + throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $argName, $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext()); } return $extractedArguments; @@ -76,7 +79,7 @@ final class CallableArgumentsExtractor $optionalArguments = []; $pos = 0; foreach ($callableParameters as $callableParameter) { - $callableParameterName = $this->normalizeName($callableParameter->name); + $callableParameterName = $callableParameter->name; if ('range' === $callable) { if ('start' === $callableParameterName) { $callableParameterName = 'low'; @@ -86,8 +89,9 @@ final class CallableArgumentsExtractor } $callableParameterNames[] = $callableParameterName; + $normalizedCallableParameterName = $this->normalizeName($callableParameterName); - if (\array_key_exists($callableParameterName, $extractedArguments)) { + if (\array_key_exists($normalizedCallableParameterName, $extractedArguments)) { if (\array_key_exists($pos, $extractedArguments)) { throw new SyntaxError(\sprintf('Argument "%s" is defined twice for %s "%s".', $callableParameterName, $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext()); } @@ -95,13 +99,13 @@ final class CallableArgumentsExtractor if (\count($missingArguments)) { throw new SyntaxError(\sprintf( 'Argument "%s" could not be assigned for %s "%s(%s)" because it is mapped to an internal PHP function which cannot determine default value for optional argument%s "%s".', - $callableParameterName, $this->twigCallable->getType(), $this->twigCallable->getName(), implode(', ', $callableParameterNames), \count($missingArguments) > 1 ? 's' : '', implode('", "', $missingArguments) + $callableParameterName, $this->twigCallable->getType(), $this->twigCallable->getName(), implode(', ', array_map([$this, 'toSnakeCase'], $callableParameterNames)), \count($missingArguments) > 1 ? 's' : '', implode('", "', $missingArguments) ), $this->node->getTemplateLine(), $this->node->getSourceContext()); } $arguments = array_merge($arguments, $optionalArguments); - $arguments[] = $extractedArguments[$callableParameterName]; - unset($extractedArguments[$callableParameterName]); + $arguments[] = $extractedArguments[$normalizedCallableParameterName]; + unset($extractedArguments[$normalizedCallableParameterName]); $optionalArguments = []; } elseif (\array_key_exists($pos, $extractedArguments)) { $arguments = array_merge($arguments, $optionalArguments); @@ -118,7 +122,7 @@ final class CallableArgumentsExtractor $missingArguments[] = $callableParameterName; } else { - throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $callableParameterName, $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext()); + throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $this->toSnakeCase($callableParameterName), $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext()); } } @@ -128,7 +132,13 @@ final class CallableArgumentsExtractor if (\is_int($key)) { $arbitraryArguments->addElement($value); } else { - $arbitraryArguments->addElement($value, new ConstantExpression($key, $this->node->getTemplateLine())); + $originalKey = $extractedArgumentNameMap[$key]; + if ($originalKey !== $this->toSnakeCase($originalKey)) { + trigger_deprecation('twig/twig', '3.15', \sprintf('Using "snake_case" for variadic arguments is required for a smooth upgrade with Twig 4.0; rename "%s" to "%s" in "%s" at line %d.', $originalKey, $this->toSnakeCase($originalKey), $this->node->getSourceContext()->getName(), $this->node->getTemplateLine())); + } + $arbitraryArguments->addElement($value, new ConstantExpression($this->toSnakeCase($originalKey), $this->node->getTemplateLine())); + // I Twig 4.0, don't convert the key: + // $arbitraryArguments->addElement($value, new ConstantExpression($originalKey, $this->node->getTemplateLine())); } unset($extractedArguments[$key]); } @@ -151,7 +161,7 @@ final class CallableArgumentsExtractor throw new SyntaxError( \sprintf( 'Unknown argument%s "%s" for %s "%s(%s)".', - \count($extractedArguments) > 1 ? 's' : '', implode('", "', array_keys($extractedArguments)), $this->twigCallable->getType(), $this->twigCallable->getName(), implode(', ', $callableParameterNames) + \count($extractedArguments) > 1 ? 's' : '', implode('", "', array_keys($extractedArguments)), $this->twigCallable->getType(), $this->twigCallable->getName(), implode(', ', array_map([$this, 'toSnakeCase'], $callableParameterNames)) ), $unknownArgument ? $unknownArgument->getTemplateLine() : $this->node->getTemplateLine(), $unknownArgument ? $unknownArgument->getSourceContext() : $this->node->getSourceContext() @@ -163,7 +173,12 @@ final class CallableArgumentsExtractor private function normalizeName(string $name): string { - return strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], ['\\1_\\2', '\\1_\\2'], $name)); + return strtolower(str_replace('_', '', $name)); + } + + private function toSnakeCase(string $name): string + { + return strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z0-9])([A-Z])/'], '\1_\2', $name)); } private function getCallableParameters(): array diff --git a/tests/Util/CallableArgumentsExtractorTest.php b/tests/Util/CallableArgumentsExtractorTest.php index cfea0f8a9..182283183 100644 --- a/tests/Util/CallableArgumentsExtractorTest.php +++ b/tests/Util/CallableArgumentsExtractorTest.php @@ -12,15 +12,20 @@ namespace Twig\Tests\Util; */ use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Twig\Error\SyntaxError; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\FunctionExpression; +use Twig\Node\Expression\VariadicExpression; use Twig\Node\Node; +use Twig\Source; use Twig\TwigFunction; use Twig\Util\CallableArgumentsExtractor; class CallableArgumentsExtractorTest extends TestCase { + use ExpectDeprecationTrait; + public function testGetArguments() { $this->assertEquals(['U', null], $this->getArguments('date', 'date', ['format' => 'U', 'timestamp' => null])); @@ -29,7 +34,7 @@ class CallableArgumentsExtractorTest extends TestCase public function testGetArgumentsWhenPositionalArgumentsAfterNamedArguments() { $this->expectException(SyntaxError::class); - $this->expectExceptionMessage('Positional arguments cannot be used after named arguments for function "date".'); + $this->expectExceptionMessage('Positional arguments cannot be used after named arguments for function "date" in "test.twig" at line 2.'); $this->getArguments('date', 'date', ['timestamp' => 123456, 'Y-m-d']); } @@ -37,7 +42,7 @@ class CallableArgumentsExtractorTest extends TestCase public function testGetArgumentsWhenArgumentIsDefinedTwice() { $this->expectException(SyntaxError::class); - $this->expectExceptionMessage('Argument "format" is defined twice for function "date".'); + $this->expectExceptionMessage('Argument "format" is defined twice for function "date" in "test.twig" at line 2.'); $this->getArguments('date', 'date', ['Y-m-d', 'format' => 'U']); } @@ -80,6 +85,54 @@ class CallableArgumentsExtractorTest extends TestCase $this->assertEquals(['arg1'], $this->getArguments('custom_static_function', __CLASS__.'::customStaticFunction', ['arg1' => 'arg1'])); } + /** + * @dataProvider getGetArgumentsConversionData + */ + public function testGetArgumentsConversion($arg1, $arg2) + { + $this->assertEquals([null], $this->getArguments('custom', eval("return fn (\$$arg1) => '';"), [$arg1 => null])); + $this->assertEquals([null], $this->getArguments('custom', eval("return fn (\$$arg2) => '';"), [$arg2 => null])); + $this->assertEquals([null], $this->getArguments('custom', eval("return fn (\$$arg1) => '';"), [$arg2 => null])); + $this->assertEquals([null], $this->getArguments('custom', eval("return fn (\$$arg2) => '';"), [$arg1 => null])); + } + + public static function getGetArgumentsConversionData() + { + yield ['some_name', 'some_name']; + yield ['someName', 'some_name']; + yield ['no_svg', 'noSVG']; + yield ['error_404', 'error404']; + yield ['errCode_404', 'err_code_404']; + yield ['errCode404', 'err_code_404']; + yield ['aBc', 'a_b_c']; + yield ['aBC', 'a_b_c']; + } + + /** + * @group legacy + */ + public function testGetArgumentsConversionForVariadics() + { + $this->expectDeprecation('Since twig/twig 3.15: Using "snake_case" for variadic arguments is required for a smooth upgrade with Twig 4.0; rename "someNumberVariadic" to "some_number_variadic" in "test.twig" at line 2.'); + + $this->assertEquals([ + new ConstantExpression('a', 0), + new ConstantExpression(12, 0), + new VariadicExpression([ + new ConstantExpression('some_text_variadic', 2), new ConstantExpression('a', 0), + new ConstantExpression('some_number_variadic', 2), new ConstantExpression(12, 0), + ], 2), + ], $this->getArguments('custom', eval("return fn (string \$someText, int \$some_number, ...\$args) => '';"), ['some_text' => 'a', 'someNumber' => 12, 'some_text_variadic' => 'a', 'someNumberVariadic' => 12], true)); + } + + public function testGetArgumentsError() + { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Value for argument "some_name" is required for function "custom_static_function" in "test.twig" at line 2.'); + + $this->getArguments('custom_static_function', [$this, 'customFunctionSnakeCamel'], ['someCity' => 'Paris']); + } + public function testResolveArgumentsWithMissingParameterForArbitraryArguments() { $this->expectException(SyntaxError::class); @@ -119,6 +172,10 @@ class CallableArgumentsExtractorTest extends TestCase { } + public function customFunctionSnakeCamel($someName, $some_city) + { + } + public function customFunctionWithArbitraryArguments() { } @@ -126,14 +183,15 @@ class CallableArgumentsExtractorTest extends TestCase private function getArguments(string $name, $callable, array $args, bool $isVariadic = false): array { $function = new TwigFunction($name, $callable, ['is_variadic' => $isVariadic]); - $node = new ExpressionCall($function, new Node([]), 0); + $node = new ExpressionCall($function, new Node([]), 2); + $node->setSourceContext(new Source('', 'test.twig')); foreach ($args as $name => $arg) { $args[$name] = new ConstantExpression($arg, 0); } $arguments = (new CallableArgumentsExtractor($node, $function))->extractArguments(new Node($args)); foreach ($arguments as $name => $argument) { - $arguments[$name] = $argument->getAttribute('value'); + $arguments[$name] = $isVariadic ? $argument : $argument->getAttribute('value'); } return $arguments;