Files
Twig/tests/ExpressionParserTest.php
Fabien Potencier f640320202 Merge branch '3.x' into 4.x
* 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
2026-08-29 00:25:20 +02:00

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'));
}
}