mirror of
https://github.com/twigphp/Twig.git
synced 2026-08-29 19:47:06 +00:00
f640320202
* 3.x: (26 commits) Remove the documentation comments compilation overhead Clarify source function trust requirements Throw on PCRE errors in the matches operator Document that reusing a non-rewindable iterator after destructuring is unsupported Release destructuring temporaries after assignment Deprecate prefixed macro definedness checks Fix duplicate macro deprecation wording Throw when list formatting fails Document that sequence destructuring consumes one value per pattern slot Fix the html_attr documentation about iterables in data attributes Warn about untrusted input with the default Tempest markdown converter Document that overriding MacroNode::compile() is not supported anymore Merge overlapping CHANGELOG entries for the destructuring fatal error fix Document that include_only keeps global variables available Remove lazy macro import resolution Honor date formatter prototype calendars Fix Stringable keys for ArrayAccess implementations Fix repeated object destructuring evaluation Restore void return type compatibility for extension points Reject destructuring patterns containing no variables ... # Conflicts: # CHANGELOG # doc/deprecated.rst # doc/filters/format_datetime.rst # extra/twig-extra-bundle/DependencyInjection/Compiler/MissingExtensionSuggestorPass.php # extra/twig-extra-bundle/DependencyInjection/TwigExtraExtension.php # extra/twig-extra-bundle/TwigExtraBundle.php # src/MacroNamespace.php # src/Node/MacrosNode.php # src/Parser.php # src/Test/IntegrationTestCase.php # src/Test/NodeTestCase.php # tests/CallMacroTest.php # tests/ExpressionParserTest.php # tests/Fixtures/macros/duplicate_definition.legacy.test # tests/Node/MacrosTest.php # tests/ParserTest.php
770 lines
31 KiB
PHP
770 lines
31 KiB
PHP
<?php
|
|
|
|
/*
|
|
* This file is part of Twig.
|
|
*
|
|
* (c) Fabien Potencier
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
namespace Twig\Tests;
|
|
|
|
/*
|
|
* This file is part of Twig.
|
|
*
|
|
* (c) Fabien Potencier
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
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\InfixExpressionParserInterface;
|
|
use Twig\ExpressionParser\Prefix\LiteralExpressionParser;
|
|
use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser;
|
|
use Twig\ExpressionParser\PrefixExpressionParserInterface;
|
|
use Twig\Extension\AbstractExtension;
|
|
use Twig\Loader\ArrayLoader;
|
|
use Twig\Node\Expression\ArrayExpression;
|
|
use Twig\Node\Expression\Binary\ConcatBinary;
|
|
use Twig\Node\Expression\Binary\ObjectDestructuringSetBinary;
|
|
use Twig\Node\Expression\Binary\SequenceDestructuringSetBinary;
|
|
use Twig\Node\Expression\ConstantExpression;
|
|
use Twig\Node\Expression\EmptyExpression;
|
|
use Twig\Node\Expression\Unary\AbstractUnary;
|
|
use Twig\Node\Expression\Unary\SpreadUnary;
|
|
use Twig\Node\Expression\Variable\AssignContextVariable;
|
|
use Twig\Node\Expression\Variable\ContextVariable;
|
|
use Twig\Parser;
|
|
use Twig\Source;
|
|
use Twig\TwigFilter;
|
|
use Twig\TwigFunction;
|
|
use Twig\TwigTest;
|
|
|
|
class ExpressionParserTest extends TestCase
|
|
{
|
|
#[DataProvider('getFailingTestsForAssignment')]
|
|
public function testCanOnlyAssignToNames($template): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$parser = new Parser($env);
|
|
|
|
$this->expectException(SyntaxError::class);
|
|
$parser->parse($env->tokenize(new Source($template, 'index')));
|
|
}
|
|
|
|
public static function getFailingTestsForAssignment()
|
|
{
|
|
return [
|
|
['{% set false = "foo" %}'],
|
|
['{% set FALSE = "foo" %}'],
|
|
['{% set true = "foo" %}'],
|
|
['{% set TRUE = "foo" %}'],
|
|
['{% set none = "foo" %}'],
|
|
['{% set NONE = "foo" %}'],
|
|
['{% set null = "foo" %}'],
|
|
['{% set NULL = "foo" %}'],
|
|
['{% set 3 = "foo" %}'],
|
|
['{% set 1 + 2 = "foo" %}'],
|
|
['{% set "bar" = "foo" %}'],
|
|
['{% set %}{% endset %}'],
|
|
];
|
|
}
|
|
|
|
#[DataProvider('getTestsForSequence')]
|
|
public function testSequenceExpression($template, $expected): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$stream = $env->tokenize($source = new Source($template, ''));
|
|
$parser = new Parser($env);
|
|
$expected->setSourceContext($source);
|
|
|
|
$this->assertEquals($expected, $parser->parse($stream)->getNode('body')->getNode(0)->getNode('expr'));
|
|
}
|
|
|
|
#[DataProvider('getFailingTestsForSequence')]
|
|
public function testSequenceSyntaxError($template): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$parser = new Parser($env);
|
|
|
|
$this->expectException(SyntaxError::class);
|
|
$parser->parse($env->tokenize(new Source($template, 'index')));
|
|
}
|
|
|
|
public static function getFailingTestsForSequence()
|
|
{
|
|
return [
|
|
['{{ [1, "a": "b"] }}'],
|
|
['{{ {"a": "b", 2} }}'],
|
|
['{{ {"a"} }}'],
|
|
];
|
|
}
|
|
|
|
public static function getTestsForSequence()
|
|
{
|
|
return [
|
|
// simple sequence
|
|
['{{ [1, 2] }}', new ArrayExpression([
|
|
new ConstantExpression(0, 1),
|
|
new ConstantExpression(1, 1),
|
|
|
|
new ConstantExpression(1, 1),
|
|
new ConstantExpression(2, 1),
|
|
], 1),
|
|
],
|
|
|
|
// sequence with trailing ,
|
|
['{{ [1, 2, ] }}', new ArrayExpression([
|
|
new ConstantExpression(0, 1),
|
|
new ConstantExpression(1, 1),
|
|
|
|
new ConstantExpression(1, 1),
|
|
new ConstantExpression(2, 1),
|
|
], 1),
|
|
],
|
|
|
|
// simple mapping
|
|
['{{ {"a": "b", "b": "c"} }}', new ArrayExpression([
|
|
new ConstantExpression('a', 1),
|
|
new ConstantExpression('b', 1),
|
|
|
|
new ConstantExpression('b', 1),
|
|
new ConstantExpression('c', 1),
|
|
], 1),
|
|
],
|
|
|
|
// mapping with trailing ,
|
|
['{{ {"a": "b", "b": "c", } }}', new ArrayExpression([
|
|
new ConstantExpression('a', 1),
|
|
new ConstantExpression('b', 1),
|
|
|
|
new ConstantExpression('b', 1),
|
|
new ConstantExpression('c', 1),
|
|
], 1),
|
|
],
|
|
|
|
// mapping in a sequence
|
|
['{{ [1, {"a": "b", "b": "c"}] }}', new ArrayExpression([
|
|
new ConstantExpression(0, 1),
|
|
new ConstantExpression(1, 1),
|
|
|
|
new ConstantExpression(1, 1),
|
|
new ArrayExpression([
|
|
new ConstantExpression('a', 1),
|
|
new ConstantExpression('b', 1),
|
|
|
|
new ConstantExpression('b', 1),
|
|
new ConstantExpression('c', 1),
|
|
], 1),
|
|
], 1),
|
|
],
|
|
|
|
// sequence in a mapping
|
|
['{{ {"a": [1, 2], "b": "c"} }}', new ArrayExpression([
|
|
new ConstantExpression('a', 1),
|
|
new ArrayExpression([
|
|
new ConstantExpression(0, 1),
|
|
new ConstantExpression(1, 1),
|
|
|
|
new ConstantExpression(1, 1),
|
|
new ConstantExpression(2, 1),
|
|
], 1),
|
|
new ConstantExpression('b', 1),
|
|
new ConstantExpression('c', 1),
|
|
], 1),
|
|
],
|
|
['{{ {a, b} }}', new ArrayExpression([
|
|
new ConstantExpression('a', 1),
|
|
new ContextVariable('a', 1),
|
|
new ConstantExpression('b', 1),
|
|
new ContextVariable('b', 1),
|
|
], 1)],
|
|
|
|
// sequence with spread operator
|
|
['{{ [1, 2, ...foo] }}',
|
|
new ArrayExpression([
|
|
new ConstantExpression(0, 1),
|
|
new ConstantExpression(1, 1),
|
|
|
|
new ConstantExpression(1, 1),
|
|
new ConstantExpression(2, 1),
|
|
|
|
new ConstantExpression(2, 1),
|
|
new SpreadUnary(new ContextVariable('foo', 1), 1),
|
|
], 1)],
|
|
|
|
// mapping with spread operator
|
|
['{{ {"a": "b", "b": "c", ...otherLetters} }}',
|
|
new ArrayExpression([
|
|
new ConstantExpression('a', 1),
|
|
new ConstantExpression('b', 1),
|
|
|
|
new ConstantExpression('b', 1),
|
|
new ConstantExpression('c', 1),
|
|
|
|
new ConstantExpression(0, 1),
|
|
new SpreadUnary(new ContextVariable('otherLetters', 1), 1),
|
|
], 1)],
|
|
];
|
|
}
|
|
|
|
public function testStringExpressionDoesNotConcatenateTwoConsecutiveStrings(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false, 'optimizations' => 0]);
|
|
$stream = $env->tokenize(new Source('{{ "a" "b" }}', 'index'));
|
|
$parser = new Parser($env);
|
|
|
|
$this->expectException(SyntaxError::class);
|
|
$parser->parse($stream);
|
|
}
|
|
|
|
public function testSequenceCompilationError(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(['index' => '{{ [1,,2] }}']), ['cache' => false, 'autoescape' => false]);
|
|
|
|
$this->expectException(SyntaxError::class);
|
|
$this->expectExceptionMessage('Empty array elements are only allowed in destructuring assignments');
|
|
$env->compileSource(new Source('{{ [1,,2] }}', 'index'));
|
|
}
|
|
|
|
public function testSequenceDestructuringUsesAssignmentTargets(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$parser = new Parser($env);
|
|
$node = $parser->parse($env->tokenize(new Source('{{ ([first, , third] = values) }}', 'index')))->getNode('body')->getNode('0')->getNode('expr');
|
|
|
|
$this->assertInstanceOf(SequenceDestructuringSetBinary::class, $node);
|
|
$pairs = $node->getNode('left')->getKeyValuePairs();
|
|
$this->assertSame(AssignContextVariable::class, $pairs[0]['value']::class);
|
|
$this->assertSame('first', $pairs[0]['value']->getAttribute('name'));
|
|
$this->assertSame(EmptyExpression::class, $pairs[1]['value']::class);
|
|
$this->assertSame(AssignContextVariable::class, $pairs[2]['value']::class);
|
|
$this->assertSame('third', $pairs[2]['value']->getAttribute('name'));
|
|
}
|
|
|
|
/**
|
|
* @dataProvider getEmptyDestructuringTests
|
|
*/
|
|
#[DataProvider('getEmptyDestructuringTests')]
|
|
public function testEmptyDestructuringThrows(string $template): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
|
|
$this->expectException(SyntaxError::class);
|
|
$this->expectExceptionMessage('Cannot destructure to an empty list of variables in "index" at line 1.');
|
|
$env->compileSource(new Source($template, 'index'));
|
|
}
|
|
|
|
public static function getEmptyDestructuringTests()
|
|
{
|
|
yield ['{% do [] = values %}'];
|
|
yield ['{% do {} = values %}'];
|
|
yield ['{% do [,] = values %}'];
|
|
yield ['{% do [,,] = values %}'];
|
|
}
|
|
|
|
public function testObjectDestructuringUsesAssignmentTargets(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$parser = new Parser($env);
|
|
$node = $parser->parse($env->tokenize(new Source('{{ ({name: user_name} = user) }}', 'index')))->getNode('body')->getNode('0')->getNode('expr');
|
|
|
|
$this->assertInstanceOf(ObjectDestructuringSetBinary::class, $node);
|
|
$pair = $node->getNode('left')->getKeyValuePairs()[0];
|
|
$this->assertSame(ConstantExpression::class, $pair['key']::class);
|
|
$this->assertSame('name', $pair['key']->getAttribute('value'));
|
|
$this->assertSame(AssignContextVariable::class, $pair['value']::class);
|
|
$this->assertSame('user_name', $pair['value']->getAttribute('name'));
|
|
}
|
|
|
|
public function testObjectDestructuringEvaluatesRightHandExpressionOnce(): void
|
|
{
|
|
$calls = 0;
|
|
$env = new Environment(new ArrayLoader(['template' => '{% do [result_first, result_second] = ({first, second} = next_value()) %}{{ first }} {{ second }} {{ result_first }} {{ result_second }}']));
|
|
$env->addFunction(new TwigFunction('next_value', static function () use (&$calls): object {
|
|
++$calls;
|
|
|
|
return (object) ['first' => $calls, 'second' => $calls];
|
|
}));
|
|
|
|
$this->assertSame('1 1 1 1', $env->render('template'));
|
|
$this->assertSame(1, $calls);
|
|
}
|
|
|
|
#[DataProvider('getTestsForString')]
|
|
public function testStringExpression($template, $expected): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false, 'optimizations' => 0]);
|
|
$stream = $env->tokenize($source = new Source($template, ''));
|
|
$parser = new Parser($env);
|
|
$expected->setSourceContext($source);
|
|
|
|
$this->assertEquals($expected, $parser->parse($stream)->getNode('body')->getNode(0)->getNode('expr'));
|
|
}
|
|
|
|
public static function getTestsForString()
|
|
{
|
|
return [
|
|
[
|
|
'{{ "foo #{bar}" }}', new ConcatBinary(
|
|
new ConstantExpression('foo ', 1),
|
|
new ContextVariable('bar', 1),
|
|
1
|
|
),
|
|
],
|
|
[
|
|
'{{ "foo #{bar} baz" }}', new ConcatBinary(
|
|
new ConcatBinary(
|
|
new ConstantExpression('foo ', 1),
|
|
new ContextVariable('bar', 1),
|
|
1
|
|
),
|
|
new ConstantExpression(' baz', 1),
|
|
1
|
|
),
|
|
],
|
|
|
|
[
|
|
'{{ "foo #{"foo #{bar} baz"} baz" }}', new ConcatBinary(
|
|
new ConcatBinary(
|
|
new ConstantExpression('foo ', 1),
|
|
new ConcatBinary(
|
|
new ConcatBinary(
|
|
new ConstantExpression('foo ', 1),
|
|
new ContextVariable('bar', 1),
|
|
1
|
|
),
|
|
new ConstantExpression(' baz', 1),
|
|
1
|
|
),
|
|
1
|
|
),
|
|
new ConstantExpression(' baz', 1),
|
|
1
|
|
),
|
|
],
|
|
];
|
|
}
|
|
|
|
#[DataProvider('getTestsForNullSafeOperator')]
|
|
public function testNullSafeOperator($template, $data, $expected): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(['template' => $template]), ['strict_variables' => true]);
|
|
|
|
$this->assertSame($expected, $env->render('template', $data));
|
|
}
|
|
|
|
public static function getTestsForNullSafeOperator()
|
|
{
|
|
return [
|
|
[
|
|
'{{ foo?.bar }}',
|
|
['foo' => (object) ['bar' => 'baz']],
|
|
'baz',
|
|
],
|
|
[
|
|
'{{ foo?.bar }}',
|
|
['foo' => null],
|
|
'',
|
|
],
|
|
[
|
|
'{{ foo?.bar?.baz }}',
|
|
['foo' => (object) ['bar' => (object) ['baz' => 'qux']]],
|
|
'qux',
|
|
],
|
|
[
|
|
'{{ foo?.bar?.baz }}',
|
|
['foo' => (object) ['bar' => null]],
|
|
'',
|
|
],
|
|
[
|
|
'{{ foo?.bar?.baz }}',
|
|
['foo' => null],
|
|
'',
|
|
],
|
|
[
|
|
'{{ foo?.bar?.baz ?? "qux" }}',
|
|
['foo' => null],
|
|
'qux',
|
|
],
|
|
[
|
|
'{{ foo?.bar ?? "qux" }}',
|
|
['foo' => (object) ['bar' => 0]],
|
|
'0',
|
|
],
|
|
[
|
|
'{{ foo?.bar ?? "qux" }}',
|
|
['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): void
|
|
{
|
|
$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.',
|
|
];
|
|
}
|
|
|
|
public function testMacroDefinitionDoesNotSupportNonNameVariableName(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$parser = new Parser($env);
|
|
|
|
$this->expectException(SyntaxError::class);
|
|
$this->expectExceptionMessage('An argument must be a name. Unexpected token "string" of value "a" ("name" expected) in "index" at line 1 column 14.');
|
|
|
|
$parser->parse($env->tokenize(new Source('{% macro foo("a") %}{% endmacro %}', 'index')));
|
|
}
|
|
|
|
#[DataProvider('getMacroDefinitionDoesNotSupportNonConstantDefaultValues')]
|
|
public function testMacroDefinitionDoesNotSupportNonConstantDefaultValues($template): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$parser = new Parser($env);
|
|
|
|
$this->expectException(SyntaxError::class);
|
|
$this->expectExceptionMessage('A default value for an argument must be a constant (a boolean, a string, a number, a sequence, or a mapping) in "index" at line 1');
|
|
|
|
$parser->parse($env->tokenize(new Source($template, 'index')));
|
|
}
|
|
|
|
public static function getMacroDefinitionDoesNotSupportNonConstantDefaultValues()
|
|
{
|
|
return [
|
|
['{% macro foo(name = "a #{foo} a") %}{% endmacro %}'],
|
|
['{% macro foo(name = [["b", "a #{foo} a"]]) %}{% endmacro %}'],
|
|
];
|
|
}
|
|
|
|
#[DataProvider('getMacroDefinitionSupportsConstantDefaultValues')]
|
|
public function testMacroDefinitionSupportsConstantDefaultValues($template): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$parser = new Parser($env);
|
|
|
|
$parser->parse($env->tokenize(new Source($template, 'index')));
|
|
|
|
// add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above
|
|
// can be executed without throwing any exceptions
|
|
$this->addToAssertionCount(1);
|
|
}
|
|
|
|
public static function getMacroDefinitionSupportsConstantDefaultValues()
|
|
{
|
|
return [
|
|
['{% macro foo(name = "aa") %}{% endmacro %}'],
|
|
['{% macro foo(name = 12) %}{% endmacro %}'],
|
|
['{% macro foo(name = true) %}{% endmacro %}'],
|
|
['{% macro foo(name = ["a"]) %}{% endmacro %}'],
|
|
['{% macro foo(name = [["a"]]) %}{% endmacro %}'],
|
|
['{% macro foo(name = {a: "a"}) %}{% endmacro %}'],
|
|
['{% macro foo(name = {a: {b: "a"}}) %}{% endmacro %}'],
|
|
];
|
|
}
|
|
|
|
public function testUnknownFunction(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$parser = new Parser($env);
|
|
|
|
$this->expectException(SyntaxError::class);
|
|
$this->expectExceptionMessage('Unknown "cycl" function. Did you mean "cycle" in "index" at line 1?');
|
|
|
|
$parser->parse($env->tokenize(new Source('{{ cycl() }}', 'index')));
|
|
}
|
|
|
|
public function testUnknownFunctionWithoutSuggestions(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$parser = new Parser($env);
|
|
|
|
$this->expectException(SyntaxError::class);
|
|
$this->expectExceptionMessage('Unknown "foobar" function in "index" at line 1.');
|
|
|
|
$parser->parse($env->tokenize(new Source('{{ foobar() }}', 'index')));
|
|
}
|
|
|
|
public function testUnknownFilter(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$parser = new Parser($env);
|
|
|
|
$this->expectException(SyntaxError::class);
|
|
$this->expectExceptionMessage('Unknown "lowe" filter. Did you mean "lower" in "index" at line 1?');
|
|
|
|
$parser->parse($env->tokenize(new Source('{{ 1|lowe }}', 'index')));
|
|
}
|
|
|
|
public function testUnknownFilterWithoutSuggestions(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$parser = new Parser($env);
|
|
|
|
$this->expectException(SyntaxError::class);
|
|
$this->expectExceptionMessage('Unknown "foobar" filter in "index" at line 1.');
|
|
|
|
$parser->parse($env->tokenize(new Source('{{ 1|foobar }}', 'index')));
|
|
}
|
|
|
|
public function testUnknownTest(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$parser = new Parser($env);
|
|
$stream = $env->tokenize(new Source('{{ 1 is nul }}', 'index'));
|
|
|
|
$this->expectException(SyntaxError::class);
|
|
$this->expectExceptionMessage('Unknown "nul" test. Did you mean "null" in "index" at line 1');
|
|
|
|
$parser->parse($stream);
|
|
}
|
|
|
|
public function testUnknownTestWithoutSuggestions(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$parser = new Parser($env);
|
|
|
|
$this->expectException(SyntaxError::class);
|
|
$this->expectExceptionMessage('Unknown "foobar" test in "index" at line 1.');
|
|
|
|
$parser->parse($env->tokenize(new Source('{{ 1 is foobar }}', 'index')));
|
|
}
|
|
|
|
public function testCompiledCodeForDynamicTest(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(['index' => '{{ "a" is foo_foo_bar_bar }}']), ['cache' => false, 'autoescape' => false]);
|
|
$env->addExtension(new class extends AbstractExtension {
|
|
public function getTests(): array
|
|
{
|
|
return [
|
|
new TwigTest('*_foo_*_bar', static function ($foo, $bar, $a) {}),
|
|
];
|
|
}
|
|
});
|
|
|
|
$this->assertStringContainsString('$this->env->getTest(\'*_foo_*_bar\')->getCallable()("foo", "bar", "a")', $env->compile($env->parse($env->tokenize(new Source($env->getLoader()->getSourceContext('index')->getCode(), 'index')))));
|
|
}
|
|
|
|
public function testCompiledCodeForDynamicFunction(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(['index' => '{{ foo_foo_bar_bar("a") }}']), ['cache' => false, 'autoescape' => false]);
|
|
$env->addExtension(new class extends AbstractExtension {
|
|
public function getFunctions(): array
|
|
{
|
|
return [
|
|
new TwigFunction('*_foo_*_bar', static function ($foo, $bar, $a) {}),
|
|
];
|
|
}
|
|
});
|
|
|
|
$this->assertStringContainsString('$this->env->getFunction(\'*_foo_*_bar\')->getCallable()("foo", "bar", "a")', $env->compile($env->parse($env->tokenize(new Source($env->getLoader()->getSourceContext('index')->getCode(), 'index')))));
|
|
}
|
|
|
|
public function testCompiledCodeForDynamicFilter(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(['index' => '{{ "a"|foo_foo_bar_bar }}']), ['cache' => false, 'autoescape' => false]);
|
|
$env->addExtension(new class extends AbstractExtension {
|
|
public function getFilters(): array
|
|
{
|
|
return [
|
|
new TwigFilter('*_foo_*_bar', static function ($foo, $bar, $a) {}),
|
|
];
|
|
}
|
|
});
|
|
|
|
$this->assertStringContainsString('$this->env->getFilter(\'*_foo_*_bar\')->getCallable()("foo", "bar", "a")', $env->compile($env->parse($env->tokenize(new Source($env->getLoader()->getSourceContext('index')->getCode(), 'index')))));
|
|
}
|
|
|
|
public function testTwoWordTestPrecedence(): void
|
|
{
|
|
// a "empty element" test must have precedence over "empty"
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$env->addTest(new TwigTest('empty element', 'foo'));
|
|
$parser = new Parser($env);
|
|
|
|
$parser->parse($env->tokenize(new Source('{{ 1 is empty element }}', 'index')));
|
|
$this->expectNotToPerformAssertions();
|
|
}
|
|
|
|
public function testUnaryPrecedenceChange(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
|
|
$env->addExtension(new class extends AbstractExtension {
|
|
public function getExpressionParsers(): array
|
|
{
|
|
$class = new class(new ConstantExpression('foo', 1), 1) extends AbstractUnary {
|
|
public function operator(Compiler $compiler): Compiler
|
|
{
|
|
return $compiler->raw('!');
|
|
}
|
|
};
|
|
|
|
return [
|
|
new UnaryOperatorExpressionParser($class::class, '!', 50),
|
|
];
|
|
}
|
|
});
|
|
$parser = new Parser($env);
|
|
|
|
$parser->parse($env->tokenize(new Source('{{ !false ? "OK" : "KO" }}', 'index')));
|
|
$this->expectNotToPerformAssertions();
|
|
}
|
|
|
|
#[DataProvider('getBindingPowerTests')]
|
|
public function testBindingPower(string $expression, string $expectedExpression, mixed $expectedResult, array $context = []): void
|
|
{
|
|
$env = new Environment(new ArrayLoader([
|
|
'expression' => $expression,
|
|
'expected' => $expectedExpression,
|
|
]));
|
|
|
|
$this->assertSame($env->render('expected', $context), $env->render('expression', $context));
|
|
$this->assertEquals($expectedResult, $env->render('expression', $context));
|
|
}
|
|
|
|
public static function getBindingPowerTests(): iterable
|
|
{
|
|
// * / // % stronger than + -
|
|
foreach (['*', '/', '//', '%'] as $op1) {
|
|
foreach (['+', '-'] as $op2) {
|
|
$e = "12 $op1 6 $op2 3";
|
|
if ('//' === $op1) {
|
|
$php = eval("return (int) floor(12 / 6) $op2 3;");
|
|
} else {
|
|
$php = eval("return $e;");
|
|
}
|
|
yield "$op1 vs $op2" => ["{{ $e }}", "{{ (12 $op1 6) $op2 3 }}", $php];
|
|
|
|
$e = "12 $op2 6 $op1 3";
|
|
if ('//' === $op1) {
|
|
$php = eval("return 12 $op2 (int) floor(6 / 3);");
|
|
} else {
|
|
$php = eval("return $e;");
|
|
}
|
|
yield "$op2 vs $op1" => ["{{ $e }}", "{{ 12 $op2 (6 $op1 3) }}", $php];
|
|
}
|
|
}
|
|
|
|
// + - * / // % stronger than == != <=> < > >= <= `not in` `in` `matches` `starts with` `ends with` `has some` `has every`
|
|
foreach (['+', '-', '*', '/', '//', '%'] as $op1) {
|
|
foreach (['==', '!=', '<=>', '<', '>', '>=', '<='] as $op2) {
|
|
$e = "12 $op1 6 $op2 3";
|
|
if ('//' === $op1) {
|
|
$php = eval("return (int) floor(12 / 6) $op2 3;");
|
|
} else {
|
|
$php = eval("return $e;");
|
|
}
|
|
yield "$op1 vs $op2" => ["{{ $e }}", "{{ (12 $op1 6) $op2 3 }}", $php];
|
|
}
|
|
}
|
|
yield '+ vs not in' => ['{{ 1 + 2 not in [3, 4] }}', '{{ (1 + 2) not in [3, 4] }}', eval('return !in_array(1 + 2, [3, 4]);')];
|
|
yield '+ vs in' => ['{{ 1 + 2 in [3, 4] }}', '{{ (1 + 2) in [3, 4] }}', eval('return in_array(1 + 2, [3, 4]);')];
|
|
yield '+ vs matches' => ['{{ 1 + 2 matches "/^3$/" }}', '{{ (1 + 2) matches "/^3$/" }}', eval("return preg_match('/^3$/', 1 + 2);")];
|
|
|
|
// ~ stronger than `starts with` `ends with`
|
|
yield '~ vs starts with' => ['{{ "a" ~ "b" starts with "a" }}', '{{ ("a" ~ "b") starts with "a" }}', eval("return str_starts_with('ab', 'a');")];
|
|
yield '~ vs ends with' => ['{{ "a" ~ "b" ends with "b" }}', '{{ ("a" ~ "b") ends with "b" }}', eval("return str_ends_with('ab', 'b');")];
|
|
|
|
// [] . stronger than anything else
|
|
$context = ['a' => ['b' => 1, 'c' => ['d' => 2]]];
|
|
yield '[] vs unary -' => ['{{ -a["b"] + 3 }}', '{{ -(a["b"]) + 3 }}', eval("\$a = ['b' => 1]; return -\$a['b'] + 3;"), $context];
|
|
yield '[] vs unary - (multiple levels)' => ['{{ -a["c"]["d"] }}', '{{ -((a["c"])["d"]) }}', eval("\$a = ['c' => ['d' => 2]]; return -\$a['c']['d'];"), $context];
|
|
yield '. vs unary -' => ['{{ -a.b }}', '{{ -(a.b) }}', eval("\$a = ['b' => 1]; return -\$a['b'];"), $context];
|
|
yield '. vs unary - (multiple levels)' => ['{{ -a.c.d }}', '{{ -((a.c).d) }}', eval("\$a = ['c' => ['d' => 2]]; return -\$a['c']['d'];"), $context];
|
|
yield '. [] vs unary -' => ['{{ -a.c["d"] }}', '{{ -((a.c)["d"]) }}', eval("\$a = ['c' => ['d' => 2]]; return -\$a['c']['d'];"), $context];
|
|
yield '[] . vs unary -' => ['{{ -a["c"].d }}', '{{ -((a["c"]).d) }}', eval("\$a = ['c' => ['d' => 2]]; return -\$a['c']['d'];"), $context];
|
|
|
|
// () stronger than anything else
|
|
yield '() vs unary -' => ['{{ -random(1, 1) + 3 }}', '{{ -(random(1, 1)) + 3 }}', eval('return -rand(1, 1) + 3;')];
|
|
|
|
// + - stronger than |
|
|
yield '+ vs |' => ['{{ 10 + 2|length }}', '{{ 10 + (2|length) }}', eval('return 10 + strlen(2);'), $context];
|
|
|
|
// - unary stronger than |
|
|
yield '- vs |' => ['{{ -1|abs }}', '{{ (-1)|abs }}', eval('return abs(-1);'), $context];
|
|
|
|
// ?? stronger than ()
|
|
yield '?? vs ()' => ['{{ (1 ?? "a") }}', '{{ ((1 ?? "a")) }}', eval('return 1;')];
|
|
|
|
// = stronger than anything else
|
|
yield '= same as literal' => ['{% do c = "a" %}{{ c }}', '{% do c = ("a") %}{{ c }}', eval("return 'a';")];
|
|
yield '= stronger than .' => ['{% do c = a.b %}{{ c }}', '{% do c = (a.b) %}{{ c }}', eval("\$a = ['b' => 1]; return \$a['b'];"), $context];
|
|
yield '= stronger than math' => ['{% do a = 1 + 3 %}{{ a }}', '{% do a = (1 + 3) %}{{ a }}', eval('$a = 1 + 3; return $a;')];
|
|
yield '= stronger than logical' => ['{% do a = false or true %}{{ a }}', '{% do a = (false or true) %}{{ a }}', eval('$a = false || true; return $a;')];
|
|
yield '= stronger than ternary' => ['{% do c = 4 ? 0 : -1 %}{{ c }}', '{% do c = (4 ? 0 : -1) %}{{ c }}', eval('return 4 ? 0 : -1;')];
|
|
}
|
|
|
|
public function testLiteralExpressionParserGetOperatorTokensReturnsEmptyArray(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader());
|
|
$parser = $env->getExpressionParsers()->getByClass(LiteralExpressionParser::class);
|
|
|
|
$this->assertSame([], $parser->getOperatorTokens());
|
|
$this->assertSame('literal', $parser->getName());
|
|
}
|
|
|
|
public function testExpressionParserGetOperatorTokensDefaultBehavior(): void
|
|
{
|
|
$env = new Environment(new ArrayLoader());
|
|
|
|
foreach ($env->getExpressionParsers() as $parser) {
|
|
if ($parser instanceof LiteralExpressionParser) {
|
|
continue;
|
|
}
|
|
$expected = [$parser->getName(), ...$parser->getAliases()];
|
|
$this->assertSame($expected, $parser->getOperatorTokens(), \sprintf('getOperatorTokens() for %s should return name + aliases.', $parser::class));
|
|
}
|
|
}
|
|
|
|
public function testLiteralIsNotRegisteredAsOperator(): void
|
|
{
|
|
// Ensure "literal" is not in the operator registry
|
|
$env = new Environment(new ArrayLoader());
|
|
$this->assertNull($env->getExpressionParsers()->getByName(PrefixExpressionParserInterface::class, 'literal'));
|
|
$this->assertNull($env->getExpressionParsers()->getByName(InfixExpressionParserInterface::class, 'literal'));
|
|
}
|
|
}
|