Merge branch '3.x' into 4.x

* 3.x:
  Update CHANGELOG
  Add `html_attr_relaxed` escaping strategy
  re-add mixed return type
  Support short-circuiting in null-safe operator chains
  Add support for renaming variables in object destructuring
  Update .gitattributes to remove splitsh.json
  Fix intro for operator precedence table ?
This commit is contained in:
Fabien Potencier
2026-02-09 12:29:34 +01:00
14 changed files with 220 additions and 21 deletions
+1
View File
@@ -8,3 +8,4 @@
/phpunit.xml.dist export-ignore
/phpstan.neon.dist export-ignore
/phpstan-baseline.neon export-ignore
/splitsh.json export-ignore
+1
View File
@@ -19,6 +19,7 @@ return (new Config())
'phpdoc_to_comment' => ['ignored_tags' => ['var']],
'ordered_imports' => true,
'phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none'],
'no_superfluous_phpdoc_tags' => ['allow_mixed' => true],
])
->setRiskyAllowed(true)
->setParallelConfig(ParallelConfigFactory::detect())
+2 -2
View File
@@ -128,8 +128,8 @@ The following options are available:
* ``autoescape`` *string*
Sets the default auto-escaping strategy (``name``, ``html``, ``js``, ``css``,
``url``, ``html_attr``, or a PHP callback that takes the template "filename"
Sets the default auto-escaping strategy (``name``, ``html``, ``js``, ``css``, ``url``,
``html_attr``, ``html_attr_relaxed``, or a PHP callback that takes the template "filename"
and returns the escaping strategy to use -- the callback cannot be a function
name to avoid collision with built-in escaping strategies); set it to
``false`` to disable auto-escaping. The ``name`` escaping strategy determines
+10
View File
@@ -57,6 +57,16 @@ documents:
also when used as the value of an HTML attribute **without quotes**
(e.g. ``data-attribute={{ some_value }}``).
* ``html_attr_relaxed``: like ``html_attr``, but **does not** escape the ``@``, ``:``,
``[`` and ``]`` characters. You may want to use this in combination with front-end
frameworks that use attribute names like ``v-bind:href`` or ``@click``. But, be
aware that in some processing contexts like XML, characters like the colon ``:``
may have meaning like for XML namespace separation.
.. versionadded:: 3.24
The ``html_attr_relaxed`` strategy has been added in 3.23.
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
documentation like `the OWASP prevention cheat sheet
+30 -2
View File
@@ -880,7 +880,8 @@ The following operators don't fit into any of the other categories:
{{ user.name }}
The null-safe operator (``?.``) works like the dot operator but returns
``null`` instead of throwing an exception when the left operand is ``null``:
``null`` instead of throwing an exception when the left operand is ``null``.
If the operand is part of a chain, the rest of the chain is skipped:
.. code-block:: twig
@@ -890,6 +891,9 @@ The following operators don't fit into any of the other categories:
{{ user?.address?.city }}
{# can be chained for safe navigation through potentially null values #}
{{ user?.address.city }}
{# returns null if user is null, the rest of the chain is skipped (address.city is not evaluated) #}
After the ``.`` or ``?.``, you can use any expression by wrapping it with
parenthesis ``()``.
@@ -1043,7 +1047,7 @@ Twig uses operators to perform various operations within templates.
Understanding the precedence of these operators is crucial for writing correct
and efficient Twig templates.
The operator precedence rules are as follows, with the lowest-precedence
The operator precedence rules are as follows, with the highest-precedence
operators listed first.
.. include:: operators_precedence.rst
@@ -1128,6 +1132,30 @@ or mapping by extracting values based on property/key names:
{{ name }} {# user.name #}
{{ email }} {# user.email #}
You can rename variables during destructuring by using the ``key: variable``
syntax, where the key is the property to extract and the variable is the name
to assign to:
.. code-block:: twig
{% do {name: userName, email: userEmail} = user %}
{{ userName }} {# user.name #}
{{ userEmail }} {# user.email #}
This is especially useful when you need to destructure multiple objects that
share the same property names:
.. code-block:: twig
{% do {data: product, error: productError} = loadProduct() %}
{% do {data: stock, error: stockError} = loadStock() %}
{{ product }} {# loadProduct().data #}
{{ productError }} {# loadProduct().error #}
{{ stock }} {# loadStock().data #}
{{ stockError }} {# loadStock().error #}
.. note::
Object destructuring uses the :ref:`dot operator <dot_operator>` to access
@@ -23,7 +23,8 @@ use Twig\Node\Node;
*/
class ObjectDestructuringSetBinary extends AbstractBinary
{
private array $properties = [];
/** @var list<array{property: string, variable: string}> */
private array $mappings = [];
/**
* @param ArrayExpression $left The array expression containing object/mapping destructuring properties
@@ -38,7 +39,11 @@ class ObjectDestructuringSetBinary extends AbstractBinary
if (!$pair['value'] instanceof ContextVariable) {
throw new SyntaxError(\sprintf('Cannot assign to "%s", only variables can be assigned in object/mapping destructuring.', $pair['value']::class), $lineno);
}
$this->properties[] = $pair['value']->getAttribute('name');
$this->mappings[] = [
'property' => $pair['key']->getAttribute('value'),
'variable' => $pair['value']->getAttribute('name'),
];
}
parent::__construct($left, $right, $lineno);
@@ -48,18 +53,18 @@ class ObjectDestructuringSetBinary extends AbstractBinary
{
$compiler->addDebugInfo($this);
$compiler->raw('[');
foreach ($this->properties as $i => $property) {
foreach ($this->mappings as $i => $mapping) {
if ($i) {
$compiler->raw(', ');
}
$compiler->raw('$context[')->repr($property)->raw(']');
$compiler->raw('$context[')->repr($mapping['variable'])->raw(']');
}
$compiler->raw('] = [');
foreach ($this->properties as $i => $property) {
foreach ($this->mappings as $i => $mapping) {
if ($i) {
$compiler->raw(', ');
}
$compiler->raw('CoreExtension::getAttribute($this->env, $this->source, ')->subcompile($this->getNode('right'))->raw(', ')->repr($property)->raw(', [], \\Twig\\Template::ANY_CALL, false, false, false, ')->repr($this->getNode('right')->getTemplateLine())->raw(')');
$compiler->raw('CoreExtension::getAttribute($this->env, $this->source, ')->subcompile($this->getNode('right'))->raw(', ')->repr($mapping['property'])->raw(', [], \\Twig\\Template::ANY_CALL, false, false, false, ')->repr($this->getNode('right')->getTemplateLine())->raw(')');
}
$compiler->raw(']');
}
+40 -8
View File
@@ -28,7 +28,7 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest
$nodes['arguments'] = $arguments;
}
parent::__construct($nodes, ['type' => $type, 'ignore_strict_check' => false, 'optimizable' => !$nullSafe, 'null_safe' => $nullSafe], $lineno);
parent::__construct($nodes, ['type' => $type, 'ignore_strict_check' => false, 'optimizable' => !$nullSafe, 'null_safe' => $nullSafe, 'is_short_circuited' => false, 'var_name' => null], $lineno);
}
public function enableDefinedTest(): void
@@ -42,7 +42,6 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest
$env = $compiler->getEnvironment();
$arrayAccessSandbox = false;
$nullSafe = $this->getAttribute('null_safe');
$objectVar = null;
// optimize array calls
if (
@@ -91,18 +90,32 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest
$this->getNode('node')->setAttribute('ignore_strict_check', true);
}
if ($nullSafe) {
$objectVar = '$'.$compiler->getVarName();
if (null === $nullSafeNode = $nullSafe ? $this : null) {
$node = $this->getNode('node');
while ($node instanceof self) {
if ($node->getAttribute('null_safe')) {
$nullSafeNode = $node;
break;
}
$node = $node->getNode('node');
}
}
$isShortCircuited = false;
if (null !== $nullSafeNode && !$nullSafeNode->isShortCircuited()) {
$compiler
->raw('((null === ('.$objectVar.' = ')
->subcompile($this->getNode('node'))
->raw('((null === ('.$nullSafeNode->getVarName($compiler).' = ')
->subcompile($nullSafeNode->getNode('node'))
->raw(')) ? null : ');
$nullSafeNode->markAsShortCircuited();
$isShortCircuited = true;
}
$compiler->raw('CoreExtension::getAttribute($this->env, $this->source, ');
if ($nullSafe) {
$compiler->raw($objectVar);
$compiler->raw($this->getVarName($compiler));
} else {
$compiler->subcompile($this->getNode('node'));
}
@@ -141,7 +154,7 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest
$compiler->raw(')');
}
if ($nullSafe) {
if ($isShortCircuited) {
$compiler->raw(')');
}
}
@@ -155,4 +168,23 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest
$this->changeIgnoreStrictCheck($node->getNode('node'));
}
}
private function markAsShortCircuited(): void
{
$this->setAttribute('is_short_circuited', true);
}
private function isShortCircuited(): bool
{
return $this->getAttribute('is_short_circuited');
}
private function getVarName(Compiler $compiler): string
{
if (null === $this->getAttribute('var_name')) {
$this->setAttribute('var_name', $compiler->getVarName());
}
return '$'.$this->getAttribute('var_name');
}
}
@@ -53,6 +53,11 @@ final class SafeAnalysisNodeVisitor implements NodeVisitorInterface
if (\in_array('html_attr', $bucket['value'], true)) {
$bucket['value'][] = 'html';
$bucket['value'][] = 'html_attr_relaxed';
}
if (\in_array('html_attr_relaxed', $bucket['value'], true)) {
$bucket['value'][] = 'html';
}
return $bucket['value'];
+9 -3
View File
@@ -124,7 +124,7 @@ final class EscaperRuntime implements RuntimeExtensionInterface
}
$string = (string) $string;
} elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'url'], true)) {
} elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'html_attr_relaxed', 'url'], true)) {
// we return the input as is (which can be of any type)
return $string;
}
@@ -256,6 +256,7 @@ final class EscaperRuntime implements RuntimeExtensionInterface
return $string;
case 'html_attr':
case 'html_attr_relaxed':
if ('UTF-8' !== $charset) {
$string = $this->convertEncoding($string, 'UTF-8', $charset);
}
@@ -264,7 +265,12 @@ final class EscaperRuntime implements RuntimeExtensionInterface
throw new RuntimeError('The string to escape is not a valid UTF-8 string.');
}
$string = preg_replace_callback('#[^a-zA-Z0-9,\.\-_]#Su', static function ($matches) {
$regex = match ($strategy) {
'html_attr' => '#[^a-zA-Z0-9,\.\-_]#Su',
'html_attr_relaxed' => '#[^a-zA-Z0-9,\.\-_:@\[\]]#Su',
};
$string = preg_replace_callback($regex, static function ($matches) {
/**
* This function is adapted from code coming from Zend Framework.
*
@@ -323,7 +329,7 @@ final class EscaperRuntime implements RuntimeExtensionInterface
return $this->escapers[$strategy]($string, $charset);
}
$validStrategies = implode('", "', array_merge(['html', 'js', 'url', 'css', 'html_attr'], array_keys($this->escapers)));
$validStrategies = implode('", "', array_merge(['html', 'js', 'url', 'css', 'html_attr', 'html_attr_relaxed'], array_keys($this->escapers)));
throw new RuntimeError(\sprintf('Invalid escaping strategy "%s" (valid ones: "%s").', $strategy, $validStrategies));
}
+44
View File
@@ -24,6 +24,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\Compiler;
use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser;
use Twig\Extension\AbstractExtension;
@@ -333,6 +334,49 @@ class ExpressionParserTest extends TestCase
['foo' => (object) ['bar' => false]],
'',
],
// short-circuiting
[
'{{ foo?.bar.baz }}',
['foo' => null],
'',
],
[
'{{ foo?.bar.baz?.qux.corge }}',
['foo' => null],
'',
],
[
'{{ foo?.bar.baz?.qux.corge }}',
['foo' => (object) ['bar' => (object) ['baz' => null]]],
'',
],
];
}
/**
* @dataProvider getTestForInvalidNullSafeOperatorShortCircuiting
*/
public function testInvalidNullSafeOperatorShortCircuiting(string $template, array $data, string $expectedMessage)
{
$env = new Environment(new ArrayLoader(['template' => $template]), ['strict_variables' => true]);
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage($expectedMessage);
$env->render('template', $data);
}
public static function getTestForInvalidNullSafeOperatorShortCircuiting()
{
yield [
'{{ foo?.bar.baz }}',
['foo' => (object) ['bar' => null]],
'Impossible to access an attribute ("baz") on a null variable in "template" at line 1.',
];
yield [
'{{ foo?.bar.baz?.qux.corge }}',
['foo' => (object) ['bar' => (object) ['baz' => (object) ['qux' => null]]]],
'Impossible to access an attribute ("corge") on a null variable in "template" at line 1.',
];
}
+12
View File
@@ -21,6 +21,12 @@ Twig supports the "=" operator (assignment)
{% do {name, email} = user %}{{ name }} {{ email }}
{% do {first_name, last_name} = user_map %}{{ first_name }} {{ last_name }}
{% do {name} = user_obj %}{{ name }}
# Object destructuring with renaming
{% do {name: userName, email: userEmail} = user %}{{ userName }} {{ userEmail }}
{% do {first_name: first, last_name: last} = user_map %}{{ first }} {{ last }}
{% do {name: objName} = user_obj %}{{ objName }}
{% do {name: n1} = user %}{% do {name: n2} = user_obj %}{{ n1 }} {{ n2 }}
--DATA--
return [
'user' => (object)['name' => 'Fabien', 'email' => 'fabien@example.com'],
@@ -47,3 +53,9 @@ one two null
Fabien fabien@example.com
Fabien Potencier
Fabien
# Object destructuring with renaming
Fabien fabien@example.com
Fabien Potencier
Fabien
Fabien Fabien
@@ -0,0 +1,15 @@
--TEST--
"escape" filter does not additionally apply the html strategy when the html_attr_relaxed strategy has been applied
"escape" filter does not additionally apply the html_attr_relaxed strategy when the html_attr strategy has been applied
--TEMPLATE--
{% autoescape 'html' %}
{{ 'v:bind@click="foo"'|escape('html_attr_relaxed') }}
{% endautoescape %}
{% autoescape 'html_attr_relaxed' %}
{{ 'v:bind@click="foo"' | escape('html_attr') }}
{% endautoescape %}
--DATA--
return []
--EXPECT--
v:bind@click&#x3D;&quot;foo&quot;
v&#x3A;bind&#x40;click&#x3D;&quot;foo&quot;
+13
View File
@@ -51,6 +51,9 @@ class GetAttrTest extends NodeTestCase
$expr = new ContextVariable('foo', 1);
$attr = new ConstantExpression('bar', 1);
$attr2 = new ConstantExpression('baz', 1);
$attr3 = new ConstantExpression('qux', 1);
$attr4 = new ConstantExpression('corge', 1);
$args = new ArrayExpression([], 1);
$node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1);
@@ -59,6 +62,16 @@ class GetAttrTest extends NodeTestCase
$node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1, true);
$tests[] = [$node, '((null === ($_v%s = // line 1'."\n".'($context["foo"] ?? null))) ? null : '.self::createAttributeGetter().'$_v%s, "bar", arguments: [], lineno: 1))', null, true];
$node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1, true);
$node = new GetAttrExpression($node, $attr2, $args, Template::METHOD_CALL, 1);
$tests[] = [$node, '((null === ($_v%s = // line 1'."\n".'($context["foo"] ?? null))) ? null : '.self::createAttributeGetter().self::createAttributeGetter().'$_v%s, "bar", [], "any", false, false, false, 1), "baz", [], "method", false, false, false, 1))', null, true];
$node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1, true);
$node = new GetAttrExpression($node, $attr2, $args, Template::ANY_CALL, 1);
$node = new GetAttrExpression($node, $attr3, $args, Template::METHOD_CALL, 1, true);
$node = new GetAttrExpression($node, $attr4, $args, Template::ANY_CALL, 1);
$tests[] = [$node, '((null === ($_v0 = ((null === ($_v1 = // line 1'."\n".'($context["foo"] ?? null))) ? null : '.self::createAttributeGetter().self::createAttributeGetter().'$_v1, "bar", [], "any", false, false, false, 1), "baz", [], "any", false, false, false, 1)))) ? null : '.self::createAttributeGetter().self::createAttributeGetter().'$_v0, "qux", [], "method", false, false, false, 1), "corge", [], "any", false, false, false, 1))', null];
$node = new GetAttrExpression($expr, $attr, $args, Template::ARRAY_CALL, 1);
$tests[] = [$node, '(($_v%s = // line 1'."\n".
'($context["foo"] ?? null)) && is_array($_v%s) || $_v%s instanceof ArrayAccess ? ($_v%s["bar"] ?? null) : null)', null, true, ];
+27
View File
@@ -180,6 +180,13 @@ class EscaperRuntimeTest extends TestCase
}
}
public function testHtmlAttributeRelaxedEscapingConvertsSpecialChars()
{
foreach ($this->htmlAttrSpecialChars as $key => $value) {
$this->assertEquals($value, (new EscaperRuntime())->escape($key, 'html_attr_relaxed'), 'Failed to escape: '.$key);
}
}
public function testJavascriptEscapingConvertsSpecialChars()
{
foreach ($this->jsSpecialChars as $key => $value) {
@@ -331,6 +338,26 @@ class EscaperRuntimeTest extends TestCase
}
}
public function testHtmlAttributeRelaxedEscapingEscapesOwaspRecommendedRanges()
{
$immune = [',', '.', '-', '_', ':', '@', '[', ']']; // Exceptions to escaping ranges
for ($chr = 0; $chr < 0xFF; ++$chr) {
if ($chr >= 0x30 && $chr <= 0x39
|| $chr >= 0x41 && $chr <= 0x5A
|| $chr >= 0x61 && $chr <= 0x7A) {
$literal = $this->codepointToUtf8($chr);
$this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed'));
} else {
$literal = $this->codepointToUtf8($chr);
if (\in_array($literal, $immune)) {
$this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed'));
} else {
$this->assertNotEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed'), "$literal should be escaped!");
}
}
}
}
public function testCssEscapingEscapesOwaspRecommendedRanges()
{
// CSS has no exceptions to escaping ranges