diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 735a09d59e3..188e4bfda08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,6 @@ jobs: strategy: matrix: php-version: - - '8.0' - '8.1' - '8.2' - '8.3' @@ -68,7 +67,6 @@ jobs: strategy: matrix: php-version: - - '8.0' - '8.1' - '8.2' - '8.3' diff --git a/CHANGELOG b/CHANGELOG index 2fafecb650e..ff480dc72cc 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # 3.19.0 (2025-XX-XX) + * Bump minimum PHP version to 8.1 + * Introduce operator classes to describe operators provided by extensions instead of arrays * Fix `constant()` behavior when used with `??` * Add the `invoke` filter * Make `{}` optional for the `types` tag diff --git a/doc/advanced.rst b/doc/advanced.rst index bc7e5d376f4..963aecf86d0 100644 --- a/doc/advanced.rst +++ b/doc/advanced.rst @@ -775,26 +775,9 @@ responsible for parsing the tag and compiling it to PHP. Operators ~~~~~~~~~ -The ``getOperators()`` methods lets you add new operators. Here is how to add -the ``!``, ``||``, and ``&&`` operators:: - - class CustomTwigExtension extends \Twig\Extension\AbstractExtension - { - public function getOperators() - { - return [ - [ - '!' => ['precedence' => 50, 'class' => \Twig\Node\Expression\Unary\NotUnary::class], - ], - [ - '||' => ['precedence' => 10, 'class' => \Twig\Node\Expression\Binary\OrBinary::class, 'associativity' => \Twig\ExpressionParser::OPERATOR_LEFT], - '&&' => ['precedence' => 15, 'class' => \Twig\Node\Expression\Binary\AndBinary::class, 'associativity' => \Twig\ExpressionParser::OPERATOR_LEFT], - ], - ]; - } - - // ... - } +The ``getOperators()`` methods lets you add new operators. To implement a new +one, have a look at the default operators provided by +``Twig\Extension\CoreExtension``. Tests ~~~~~ diff --git a/doc/deprecated.rst b/doc/deprecated.rst index c3d6c32ac6a..38e536ef9ef 100644 --- a/doc/deprecated.rst +++ b/doc/deprecated.rst @@ -411,3 +411,26 @@ Operators {# or #} {{ (not 1) * 2 }} {# this is equivalent to what Twig 4.x will do without the parentheses #} + +* Operators are now instances of ``Twig\Operator\OperatorInterface`` instead of + arrays. The ``ExtensionInterface::getOperators()`` method should now return an + array of ``Twig\Operator\OperatorInterface`` instances. + + Before: + + public function getOperators(): array { + return [ + 'not' => [ + 'precedence' => 10, + 'class' => NotUnaryOperator::class, + ], + ]; + } + + After: + + public function getOperators(): Operators { + return new Operators([ + new NotUnaryOperator(), + ]); + } diff --git a/src/Environment.php b/src/Environment.php index 430d3991195..9278691961b 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -27,11 +27,10 @@ use Twig\Loader\ArrayLoader; use Twig\Loader\ChainLoader; use Twig\Loader\LoaderInterface; -use Twig\Node\Expression\Binary\AbstractBinary; -use Twig\Node\Expression\Unary\AbstractUnary; use Twig\Node\ModuleNode; use Twig\Node\Node; use Twig\NodeVisitor\NodeVisitorInterface; +use Twig\Operator\Operators; use Twig\Runtime\EscaperRuntime; use Twig\RuntimeLoader\FactoryRuntimeLoader; use Twig\RuntimeLoader\RuntimeLoaderInterface; @@ -862,22 +861,10 @@ public function mergeGlobals(array $context): array /** * @internal - * - * @return array}> - */ - public function getUnaryOperators(): array - { - return $this->extensionSet->getUnaryOperators(); - } - - /** - * @internal - * - * @return array, associativity: ExpressionParser::OPERATOR_*}> */ - public function getBinaryOperators(): array + public function getOperators(): Operators { - return $this->extensionSet->getBinaryOperators(); + return $this->extensionSet->getOperators(); } private function updateOptionsHash(): void diff --git a/src/ExpressionParser.php b/src/ExpressionParser.php index 0f1b0ed366a..5cf5a0e4ad8 100644 --- a/src/ExpressionParser.php +++ b/src/ExpressionParser.php @@ -18,7 +18,6 @@ use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ArrowFunctionExpression; -use Twig\Node\Expression\Binary\AbstractBinary; use Twig\Node\Expression\Binary\ConcatBinary; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\GetAttrExpression; @@ -26,7 +25,6 @@ use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Ternary\ConditionalTernary; use Twig\Node\Expression\TestExpression; -use Twig\Node\Expression\Unary\AbstractUnary; use Twig\Node\Expression\Unary\NegUnary; use Twig\Node\Expression\Unary\NotUnary; use Twig\Node\Expression\Unary\PosUnary; @@ -37,6 +35,9 @@ use Twig\Node\Expression\Variable\TemplateVariable; use Twig\Node\Node; use Twig\Node\Nodes; +use Twig\Operator\OperatorArity; +use Twig\Operator\OperatorAssociativity; +use Twig\Operator\Operators; /** * Parses expressions. @@ -50,41 +51,33 @@ */ class ExpressionParser { + // deprecated, to be removed in 4.0 public const OPERATOR_LEFT = 1; public const OPERATOR_RIGHT = 2; - /** @var array}> */ - private $unaryOperators; - /** @var array, associativity: self::OPERATOR_*}> */ - private $binaryOperators; + private Operators $operators; private $readyNodes = []; - private array $precedenceChanges = []; + private \WeakMap $precedenceChanges; private bool $deprecationCheck = true; public function __construct( private Parser $parser, private Environment $env, ) { - $this->unaryOperators = $env->getUnaryOperators(); - $this->binaryOperators = $env->getBinaryOperators(); - - $ops = []; - foreach ($this->unaryOperators as $n => $c) { - $ops[] = $c + ['name' => $n, 'type' => 'unary']; - } - foreach ($this->binaryOperators as $n => $c) { - $ops[] = $c + ['name' => $n, 'type' => 'binary']; - } - foreach ($ops as $config) { - if (!isset($config['precedence_change'])) { + $this->operators = $env->getOperators(); + $this->precedenceChanges = new \WeakMap(); + foreach ($this->operators as $op) { + if (!$op->getPrecedenceChange()) { continue; } - $name = $config['type'].'_'.$config['name']; - $min = min($config['precedence_change']->getNewPrecedence(), $config['precedence']); - $max = max($config['precedence_change']->getNewPrecedence(), $config['precedence']); - foreach ($ops as $c) { - if ($c['precedence'] > $min && $c['precedence'] < $max) { - $this->precedenceChanges[$c['type'].'_'.$c['name']][] = $name; + $min = min($op->getPrecedenceChange()->getNewPrecedence(), $op->getPrecedence()); + $max = max($op->getPrecedenceChange()->getNewPrecedence(), $op->getPrecedence()); + foreach ($this->operators as $o) { + if ($o->getPrecedence() > $min && $o->getPrecedence() < $max) { + if (!isset($this->precedenceChanges[$o])) { + $this->precedenceChanges[$o] = []; + } + $this->precedenceChanges[$o][] = $op; } } } @@ -102,28 +95,27 @@ public function parseExpression($precedence = 0) $expr = $this->getPrimary(); $token = $this->parser->getCurrentToken(); - while ($this->isBinary($token) && $this->binaryOperators[$token->getValue()]['precedence'] >= $precedence) { - $op = $this->binaryOperators[$token->getValue()]; + while ($token->test(Token::OPERATOR_TYPE) && ($op = $this->operators->getBinary($token->getValue())) && $op->getPrecedence() >= $precedence) { $this->parser->getStream()->next(); if ('is not' === $token->getValue()) { $expr = $this->parseNotTestExpression($expr); } elseif ('is' === $token->getValue()) { $expr = $this->parseTestExpression($expr); - } elseif (isset($op['callable'])) { - $expr = $op['callable']($this->parser, $expr); + } elseif (null !== $op->getCallable()) { + $expr = $op->getCallable()($this->parser, $expr); } else { $previous = $this->setDeprecationCheck(true); try { - $expr1 = $this->parseExpression(self::OPERATOR_LEFT === $op['associativity'] ? $op['precedence'] + 1 : $op['precedence']); + $expr1 = $this->parseExpression(OperatorAssociativity::Left === $op->getAssociativity() ? $op->getPrecedence() + 1 : $op->getPrecedence()); } finally { $this->setDeprecationCheck($previous); } - $class = $op['class']; + $class = $op->getNodeClass(); $expr = new $class($expr, $expr1, $token->getLine()); } - $expr->setAttribute('operator', 'binary_'.$token->getValue()); + $expr->setAttribute('operator', $op); $this->triggerPrecedenceDeprecations($expr); @@ -144,30 +136,29 @@ private function triggerPrecedenceDeprecations(AbstractExpression $expr): void return; } - if (str_starts_with($unaryOp = $expr->getAttribute('operator'), 'unary')) { + if (OperatorArity::Unary === $expr->getAttribute('operator')->getArity()) { if ($expr->hasExplicitParentheses()) { return; } - $target = explode('_', $unaryOp)[1]; + $operator = $expr->getAttribute('operator'); /** @var AbstractExpression $node */ $node = $expr->getNode('node'); - foreach ($this->precedenceChanges as $operatorName => $changes) { - if (!\in_array($unaryOp, $changes)) { + foreach ($this->precedenceChanges as $op => $changes) { + if (!\in_array($operator, $changes, true)) { continue; } - if ($node->hasAttribute('operator') && $operatorName === $node->getAttribute('operator')) { - $change = $this->unaryOperators[$target]['precedence_change']; - trigger_deprecation($change->getPackage(), $change->getVersion(), \sprintf('Add explicit parentheses around the "%s" unary operator to avoid behavior change in the next major version as its precedence will change in "%s" at line %d.', $target, $this->parser->getStream()->getSourceContext()->getName(), $node->getTemplateLine())); + if ($node->hasAttribute('operator') && $op === $node->getAttribute('operator')) { + $change = $operator->getPrecedenceChange(); + trigger_deprecation($change->getPackage(), $change->getVersion(), \sprintf('Add explicit parentheses around the "%s" unary operator to avoid behavior change in the next major version as its precedence will change in "%s" at line %d.', $operator->getOperator(), $this->parser->getStream()->getSourceContext()->getName(), $node->getTemplateLine())); } } } else { - foreach ($this->precedenceChanges[$expr->getAttribute('operator')] as $operatorName) { + foreach ($this->precedenceChanges[$expr->getAttribute('operator')] as $operator) { foreach ($expr as $node) { /** @var AbstractExpression $node */ - if ($node->hasAttribute('operator') && $operatorName === $node->getAttribute('operator') && !$node->hasExplicitParentheses()) { - $op = explode('_', $operatorName)[1]; - $change = $this->binaryOperators[$op]['precedence_change']; - trigger_deprecation($change->getPackage(), $change->getVersion(), \sprintf('Add explicit parentheses around the "%s" binary operator to avoid behavior change in the next major version as its precedence will change in "%s" at line %d.', $op, $this->parser->getStream()->getSourceContext()->getName(), $node->getTemplateLine())); + if ($node->hasAttribute('operator') && $operator === $node->getAttribute('operator') && !$node->hasExplicitParentheses()) { + $change = $operator->getPrecedenceChange(); + trigger_deprecation($change->getPackage(), $change->getVersion(), \sprintf('Add explicit parentheses around the "%s" binary operator to avoid behavior change in the next major version as its precedence will change in "%s" at line %d.', $operator->getOperator(), $this->parser->getStream()->getSourceContext()->getName(), $node->getTemplateLine())); } } } @@ -236,14 +227,13 @@ private function getPrimary(): AbstractExpression { $token = $this->parser->getCurrentToken(); - if ($this->isUnary($token)) { - $operator = $this->unaryOperators[$token->getValue()]; + if ($token->test(Token::OPERATOR_TYPE) && $operator = $this->operators->getUnary($token->getValue())) { $this->parser->getStream()->next(); - $expr = $this->parseExpression($operator['precedence']); - $class = $operator['class']; + $expr = $this->parseExpression($operator->getPrecedence()); + $class = $operator->getNodeClass(); $expr = new $class($expr, $token->getLine()); - $expr->setAttribute('operator', 'unary_'.$token->getValue()); + $expr->setAttribute('operator', $operator); if ($this->deprecationCheck) { $this->triggerPrecedenceDeprecations($expr); @@ -284,16 +274,6 @@ private function parseConditionalExpression($expr): AbstractExpression return $expr; } - private function isUnary(Token $token): bool - { - return $token->test(Token::OPERATOR_TYPE) && isset($this->unaryOperators[$token->getValue()]); - } - - private function isBinary(Token $token): bool - { - return $token->test(Token::OPERATOR_TYPE) && isset($this->binaryOperators[$token->getValue()]); - } - public function parsePrimaryExpression() { $token = $this->parser->getCurrentToken(); diff --git a/src/Extension/AbstractExtension.php b/src/Extension/AbstractExtension.php index 26c00c68066..02767f7c37c 100644 --- a/src/Extension/AbstractExtension.php +++ b/src/Extension/AbstractExtension.php @@ -40,7 +40,7 @@ public function getFunctions() public function getOperators() { - return [[], []]; + return []; } public function getLastModified(): int diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index b83a0fed1db..03141d5ae58 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -16,40 +16,8 @@ use Twig\Error\LoaderError; use Twig\Error\RuntimeError; use Twig\Error\SyntaxError; -use Twig\ExpressionParser; use Twig\Markup; use Twig\Node\Expression\AbstractExpression; -use Twig\Node\Expression\Binary\AddBinary; -use Twig\Node\Expression\Binary\AndBinary; -use Twig\Node\Expression\Binary\BitwiseAndBinary; -use Twig\Node\Expression\Binary\BitwiseOrBinary; -use Twig\Node\Expression\Binary\BitwiseXorBinary; -use Twig\Node\Expression\Binary\ConcatBinary; -use Twig\Node\Expression\Binary\DivBinary; -use Twig\Node\Expression\Binary\ElvisBinary; -use Twig\Node\Expression\Binary\EndsWithBinary; -use Twig\Node\Expression\Binary\EqualBinary; -use Twig\Node\Expression\Binary\FloorDivBinary; -use Twig\Node\Expression\Binary\GreaterBinary; -use Twig\Node\Expression\Binary\GreaterEqualBinary; -use Twig\Node\Expression\Binary\HasEveryBinary; -use Twig\Node\Expression\Binary\HasSomeBinary; -use Twig\Node\Expression\Binary\InBinary; -use Twig\Node\Expression\Binary\LessBinary; -use Twig\Node\Expression\Binary\LessEqualBinary; -use Twig\Node\Expression\Binary\MatchesBinary; -use Twig\Node\Expression\Binary\ModBinary; -use Twig\Node\Expression\Binary\MulBinary; -use Twig\Node\Expression\Binary\NotEqualBinary; -use Twig\Node\Expression\Binary\NotInBinary; -use Twig\Node\Expression\Binary\NullCoalesceBinary; -use Twig\Node\Expression\Binary\OrBinary; -use Twig\Node\Expression\Binary\PowerBinary; -use Twig\Node\Expression\Binary\RangeBinary; -use Twig\Node\Expression\Binary\SpaceshipBinary; -use Twig\Node\Expression\Binary\StartsWithBinary; -use Twig\Node\Expression\Binary\SubBinary; -use Twig\Node\Expression\Binary\XorBinary; use Twig\Node\Expression\BlockReferenceExpression; use Twig\Node\Expression\Filter\DefaultFilter; use Twig\Node\Expression\FunctionNode\EnumCasesFunction; @@ -63,11 +31,43 @@ use Twig\Node\Expression\Test\NullTest; use Twig\Node\Expression\Test\OddTest; use Twig\Node\Expression\Test\SameasTest; -use Twig\Node\Expression\Unary\NegUnary; -use Twig\Node\Expression\Unary\NotUnary; -use Twig\Node\Expression\Unary\PosUnary; use Twig\Node\Node; -use Twig\OperatorPrecedenceChange; +use Twig\Operator\Binary\AddBinaryOperator; +use Twig\Operator\Binary\AndBinaryOperator; +use Twig\Operator\Binary\BitwiseAndBinaryOperator; +use Twig\Operator\Binary\BitwiseOrBinaryOperator; +use Twig\Operator\Binary\BitwiseXorBinaryOperator; +use Twig\Operator\Binary\ConcatBinaryOperator; +use Twig\Operator\Binary\DivBinaryOperator; +use Twig\Operator\Binary\ElvisBinaryOperator; +use Twig\Operator\Binary\EndsWithBinaryOperator; +use Twig\Operator\Binary\EqualBinaryOperator; +use Twig\Operator\Binary\FloorDivBinaryOperator; +use Twig\Operator\Binary\GreaterBinaryOperator; +use Twig\Operator\Binary\GreaterEqualBinaryOperator; +use Twig\Operator\Binary\HasEveryBinaryOperator; +use Twig\Operator\Binary\HasSomeBinaryOperator; +use Twig\Operator\Binary\InBinaryOperator; +use Twig\Operator\Binary\IsBinaryOperator; +use Twig\Operator\Binary\IsNotBinaryOperator; +use Twig\Operator\Binary\LessBinaryOperator; +use Twig\Operator\Binary\LessEqualBinaryOperator; +use Twig\Operator\Binary\MatchesBinaryOperator; +use Twig\Operator\Binary\ModBinaryOperator; +use Twig\Operator\Binary\MulBinaryOperator; +use Twig\Operator\Binary\NotEqualBinaryOperator; +use Twig\Operator\Binary\NotInBinaryOperator; +use Twig\Operator\Binary\NullCoalesceBinaryOperator; +use Twig\Operator\Binary\OrBinaryOperator; +use Twig\Operator\Binary\PowerBinaryOperator; +use Twig\Operator\Binary\RangeBinaryOperator; +use Twig\Operator\Binary\SpaceshipBinaryOperator; +use Twig\Operator\Binary\StartsWithBinaryOperator; +use Twig\Operator\Binary\SubBinaryOperator; +use Twig\Operator\Binary\XorBinaryOperator; +use Twig\Operator\Unary\NegUnaryOperator; +use Twig\Operator\Unary\NotUnaryOperator; +use Twig\Operator\Unary\PosUnaryOperator; use Twig\Parser; use Twig\Sandbox\SecurityNotAllowedMethodError; use Twig\Sandbox\SecurityNotAllowedPropertyError; @@ -316,47 +316,43 @@ public function getNodeVisitors(): array public function getOperators(): array { return [ - [ - 'not' => ['precedence' => 50, 'precedence_change' => new OperatorPrecedenceChange('twig/twig', '3.15', 70), 'class' => NotUnary::class], - '-' => ['precedence' => 500, 'class' => NegUnary::class], - '+' => ['precedence' => 500, 'class' => PosUnary::class], - ], - [ - '? :' => ['precedence' => 5, 'class' => ElvisBinary::class, 'associativity' => ExpressionParser::OPERATOR_RIGHT], - '?:' => ['precedence' => 5, 'class' => ElvisBinary::class, 'associativity' => ExpressionParser::OPERATOR_RIGHT], - '??' => ['precedence' => 300, 'precedence_change' => new OperatorPrecedenceChange('twig/twig', '3.15', 5), 'class' => NullCoalesceBinary::class, 'associativity' => ExpressionParser::OPERATOR_RIGHT], - 'or' => ['precedence' => 10, 'class' => OrBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'xor' => ['precedence' => 12, 'class' => XorBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'and' => ['precedence' => 15, 'class' => AndBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'b-or' => ['precedence' => 16, 'class' => BitwiseOrBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'b-xor' => ['precedence' => 17, 'class' => BitwiseXorBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'b-and' => ['precedence' => 18, 'class' => BitwiseAndBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '==' => ['precedence' => 20, 'class' => EqualBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '!=' => ['precedence' => 20, 'class' => NotEqualBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '<=>' => ['precedence' => 20, 'class' => SpaceshipBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '<' => ['precedence' => 20, 'class' => LessBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '>' => ['precedence' => 20, 'class' => GreaterBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '>=' => ['precedence' => 20, 'class' => GreaterEqualBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '<=' => ['precedence' => 20, 'class' => LessEqualBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'not in' => ['precedence' => 20, 'class' => NotInBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'in' => ['precedence' => 20, 'class' => InBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'matches' => ['precedence' => 20, 'class' => MatchesBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'starts with' => ['precedence' => 20, 'class' => StartsWithBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'ends with' => ['precedence' => 20, 'class' => EndsWithBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'has some' => ['precedence' => 20, 'class' => HasSomeBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'has every' => ['precedence' => 20, 'class' => HasEveryBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '..' => ['precedence' => 25, 'class' => RangeBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '+' => ['precedence' => 30, 'class' => AddBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '-' => ['precedence' => 30, 'class' => SubBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '~' => ['precedence' => 40, 'precedence_change' => new OperatorPrecedenceChange('twig/twig', '3.15', 27), 'class' => ConcatBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '*' => ['precedence' => 60, 'class' => MulBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '/' => ['precedence' => 60, 'class' => DivBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '//' => ['precedence' => 60, 'class' => FloorDivBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '%' => ['precedence' => 60, 'class' => ModBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'is' => ['precedence' => 100, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'is not' => ['precedence' => 100, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '**' => ['precedence' => 200, 'class' => PowerBinary::class, 'associativity' => ExpressionParser::OPERATOR_RIGHT], - ], + new NotUnaryOperator(), + new NegUnaryOperator(), + new PosUnaryOperator(), + + new ElvisBinaryOperator(), + new NullCoalesceBinaryOperator(), + new OrBinaryOperator(), + new XorBinaryOperator(), + new AndBinaryOperator(), + new BitwiseOrBinaryOperator(), + new BitwiseXorBinaryOperator(), + new BitwiseAndBinaryOperator(), + new EqualBinaryOperator(), + new NotEqualBinaryOperator(), + new SpaceshipBinaryOperator(), + new LessBinaryOperator(), + new GreaterBinaryOperator(), + new GreaterEqualBinaryOperator(), + new LessEqualBinaryOperator(), + new NotInBinaryOperator(), + new InBinaryOperator(), + new MatchesBinaryOperator(), + new StartsWithBinaryOperator(), + new EndsWithBinaryOperator(), + new HasSomeBinaryOperator(), + new HasEveryBinaryOperator(), + new RangeBinaryOperator(), + new AddBinaryOperator(), + new SubBinaryOperator(), + new ConcatBinaryOperator(), + new MulBinaryOperator(), + new DivBinaryOperator(), + new FloorDivBinaryOperator(), + new ModBinaryOperator(), + new IsBinaryOperator(), + new IsNotBinaryOperator(), + new PowerBinaryOperator(), ]; } diff --git a/src/Extension/ExtensionInterface.php b/src/Extension/ExtensionInterface.php index d51cd3ee2ff..6d5e4b5fa7f 100644 --- a/src/Extension/ExtensionInterface.php +++ b/src/Extension/ExtensionInterface.php @@ -11,11 +11,8 @@ namespace Twig\Extension; -use Twig\ExpressionParser; -use Twig\Node\Expression\Binary\AbstractBinary; -use Twig\Node\Expression\Unary\AbstractUnary; use Twig\NodeVisitor\NodeVisitorInterface; -use Twig\OperatorPrecedenceChange; +use Twig\Operator\OperatorInterface; use Twig\TokenParser\TokenParserInterface; use Twig\TwigFilter; use Twig\TwigFunction; @@ -66,12 +63,7 @@ public function getFunctions(); /** * Returns a list of operators to add to the existing list. * - * @return array First array of unary operators, second array of binary operators - * - * @psalm-return array{ - * array}>, - * array, associativity: ExpressionParser::OPERATOR_*}> - * } + * @return OperatorInterface[] */ public function getOperators(); } diff --git a/src/ExtensionSet.php b/src/ExtensionSet.php index 18979228a7d..2635ac94918 100644 --- a/src/ExtensionSet.php +++ b/src/ExtensionSet.php @@ -16,9 +16,12 @@ use Twig\Extension\GlobalsInterface; use Twig\Extension\LastModifiedExtensionInterface; use Twig\Extension\StagingExtension; -use Twig\Node\Expression\Binary\AbstractBinary; -use Twig\Node\Expression\Unary\AbstractUnary; use Twig\NodeVisitor\NodeVisitorInterface; +use Twig\Operator\Binary\AbstractBinaryOperator; +use Twig\Operator\OperatorAssociativity; +use Twig\Operator\OperatorInterface; +use Twig\Operator\Operators; +use Twig\Operator\Unary\AbstractUnaryOperator; use Twig\TokenParser\TokenParserInterface; /** @@ -46,10 +49,8 @@ final class ExtensionSet private $functions; /** @var array */ private $dynamicFunctions; - /** @var array}> */ - private $unaryOperators; - /** @var array, associativity: ExpressionParser::OPERATOR_*}> */ - private $binaryOperators; + /** @var Operators */ + private $operators; /** @var array|null */ private $globals; private $functionCallbacks = []; @@ -391,28 +392,13 @@ public function getTest(string $name): ?TwigTest return null; } - /** - * @return array}> - */ - public function getUnaryOperators(): array + public function getOperators(): Operators { if (!$this->initialized) { $this->initExtensions(); } - return $this->unaryOperators; - } - - /** - * @return array, associativity: ExpressionParser::OPERATOR_*}> - */ - public function getBinaryOperators(): array - { - if (!$this->initialized) { - $this->initExtensions(); - } - - return $this->binaryOperators; + return $this->operators; } private function initExtensions(): void @@ -425,8 +411,7 @@ private function initExtensions(): void $this->dynamicFunctions = []; $this->dynamicTests = []; $this->visitors = []; - $this->unaryOperators = []; - $this->binaryOperators = []; + $this->operators = new Operators(); foreach ($this->extensions as $extension) { $this->initExtension($extension); @@ -482,12 +467,110 @@ private function initExtension(ExtensionInterface $extension): void throw new \InvalidArgumentException(\sprintf('"%s::getOperators()" must return an array with operators, got "%s".', \get_class($extension), get_debug_type($operators).(\is_resource($operators) ? '' : '#'.$operators))); } - if (2 !== \count($operators)) { - throw new \InvalidArgumentException(\sprintf('"%s::getOperators()" must return an array of 2 elements, got %d.', \get_class($extension), \count($operators))); + // new signature? + $legacy = false; + foreach ($operators as $op) { + if (!$op instanceof OperatorInterface) { + $legacy = true; + + break; + } } - $this->unaryOperators = array_merge($this->unaryOperators, $operators[0]); - $this->binaryOperators = array_merge($this->binaryOperators, $operators[1]); + if ($legacy) { + if (2 !== \count($operators)) { + throw new \InvalidArgumentException(\sprintf('"%s::getOperators()" must return an array of 2 elements, got %d.', \get_class($extension), \count($operators))); + } + + trigger_deprecation('twig/twig', '3.19.0', \sprintf('Extension "%s" uses the old signature for "getOperators()", please update it to return an array of "OperatorInterface" objects.', \get_class($extension))); + + $ops = []; + foreach ($operators[0] as $n => $op) { + $ops[] = $op instanceof OperatorInterface ? $op : $this->convertUnaryOperators($n, $op); + } + foreach ($operators[1] as $n => $op) { + $ops[] = $op instanceof OperatorInterface ? $op : $this->convertBinaryOperators($n, $op); + } + $this->operators->add($ops); + } else { + $this->operators->add($operators); + } } } + + private function convertUnaryOperators(string $n, array $op): OperatorInterface + { + trigger_deprecation('twig/twig', '3.19.0', \sprintf('Using a non-OperatorInterface object to define the "%s" unary operator is deprecated.', $n)); + + return new class($op, $n) extends AbstractUnaryOperator { + public function __construct(private array $op, private string $operator) + { + } + + public function getOperator(): string + { + return $this->operator; + } + + public function getPrecedence(): int + { + return $this->op['precedence']; + } + + public function getPrecedenceChange(): ?OperatorPrecedenceChange + { + return $this->op['precedence_change'] ?? null; + } + + public function getNodeClass(): string + { + return $this->op['class']; + } + }; + } + + private function convertBinaryOperators(string $n, array $op): OperatorInterface + { + trigger_deprecation('twig/twig', '3.19.0', \sprintf('Using a non-OperatorInterface object to define the "%s" binary operator is deprecated.', $n)); + + return new class($op, $n) extends AbstractBinaryOperator { + public function __construct(private array $op, private string $operator) + { + } + + public function getOperator(): string + { + return $this->operator; + } + + public function getPrecedence(): int + { + return $this->op['precedence']; + } + + public function getPrecedenceChange(): ?OperatorPrecedenceChange + { + return $this->op['precedence_change'] ?? null; + } + + public function getNodeClass(): string + { + return $this->op['class'] ?? ''; + } + + public function getAssociativity(): OperatorAssociativity + { + return match ($this->op['associativity']) { + 1 => OperatorAssociativity::Left, + 2 => OperatorAssociativity::Right, + default => throw new \InvalidArgumentException(\sprintf('Invalid associativity "%s" for operator "%s".', $this->op['associativity'], $this->getOperator())), + }; + } + + public function getCallable(): ?callable + { + return $this->op['callable'] ?? null; + } + }; + } } diff --git a/src/Lexer.php b/src/Lexer.php index e2a030b18c2..a3364a75421 100644 --- a/src/Lexer.php +++ b/src/Lexer.php @@ -544,11 +544,10 @@ private function moveCursor($text): void private function getOperatorRegex(): string { - $operators = array_merge( - ['='], - array_keys($this->env->getUnaryOperators()), - array_keys($this->env->getBinaryOperators()) - ); + $operators = ['=']; + foreach ($this->env->getOperators() as $operator) { + $operators = array_merge($operators, [$operator->getOperator()], $operator->getAliases()); + } $operators = array_combine($operators, array_map('strlen', $operators)); arsort($operators); diff --git a/src/Operator/AbstractOperator.php b/src/Operator/AbstractOperator.php new file mode 100644 index 00000000000..6be69db3b92 --- /dev/null +++ b/src/Operator/AbstractOperator.php @@ -0,0 +1,37 @@ +getArity()->value, $this->getOperator()); + } + + public function getPrecedenceChange(): ?OperatorPrecedenceChange + { + return null; + } + + public function getAliases(): array + { + return []; + } + + public function getId(): string + { + return $this->getArity()->value.'_'.$this->getOperator(); + } +} diff --git a/src/Operator/Binary/AbstractBinaryOperator.php b/src/Operator/Binary/AbstractBinaryOperator.php new file mode 100644 index 00000000000..2eec2537f3c --- /dev/null +++ b/src/Operator/Binary/AbstractBinaryOperator.php @@ -0,0 +1,34 @@ +'; + } + + public function getPrecedence(): int + { + return 20; + } + + public function getNodeClass(): string + { + return GreaterBinary::class; + } +} diff --git a/src/Operator/Binary/GreaterEqualBinaryOperator.php b/src/Operator/Binary/GreaterEqualBinaryOperator.php new file mode 100644 index 00000000000..b10b4c28013 --- /dev/null +++ b/src/Operator/Binary/GreaterEqualBinaryOperator.php @@ -0,0 +1,32 @@ +='; + } + + public function getPrecedence(): int + { + return 20; + } +} diff --git a/src/Operator/Binary/HasEveryBinaryOperator.php b/src/Operator/Binary/HasEveryBinaryOperator.php new file mode 100644 index 00000000000..76a03e5f3ca --- /dev/null +++ b/src/Operator/Binary/HasEveryBinaryOperator.php @@ -0,0 +1,32 @@ +'; + } + + public function getPrecedence(): int + { + return 20; + } + + public function getNodeClass(): string + { + return SpaceshipBinary::class; + } +} diff --git a/src/Operator/Binary/StartsWithBinaryOperator.php b/src/Operator/Binary/StartsWithBinaryOperator.php new file mode 100644 index 00000000000..3c6f417f60a --- /dev/null +++ b/src/Operator/Binary/StartsWithBinaryOperator.php @@ -0,0 +1,32 @@ +add($operators); + } + + /** + * @param array $operators + * + * @return $this + */ + public function add(array $operators): self + { + foreach ($operators as $operator) { + $this->operators[$operator->getArity()->value][$operator->getOperator()] = $operator; + foreach ($operator->getAliases() as $alias) { + $this->aliases[$operator->getArity()->value][$alias] = $operator; + } + } + + return $this; + } + + public function getUnary(string $name): ?AbstractUnaryOperator + { + return $this->operators[OperatorArity::Unary->value][$name] ?? ($this->aliases[OperatorArity::Unary->value][$name] ?? null); + } + + public function getBinary(string $name): ?AbstractBinaryOperator + { + return $this->operators[OperatorArity::Binary->value][$name] ?? ($this->aliases[OperatorArity::Binary->value][$name] ?? null); + } + + public function getIterator(): \Traversable + { + foreach ($this->operators as $operators) { + // we don't yield the keys + yield from $operators; + } + } +} diff --git a/src/Operator/Ternary/AbstractTernaryOperator.php b/src/Operator/Ternary/AbstractTernaryOperator.php new file mode 100644 index 00000000000..eceaa79a1e0 --- /dev/null +++ b/src/Operator/Ternary/AbstractTernaryOperator.php @@ -0,0 +1,23 @@ +expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($expectedExceptionMessage); - $env->getUnaryOperators(); + $env->getOperators(); } public static function provideInvalidExtensions() diff --git a/tests/EnvironmentTest.php b/tests/EnvironmentTest.php index 34774db1c5b..5bc90b58215 100644 --- a/tests/EnvironmentTest.php +++ b/tests/EnvironmentTest.php @@ -26,6 +26,8 @@ use Twig\Loader\LoaderInterface; use Twig\Node\Node; use Twig\NodeVisitor\NodeVisitorInterface; +use Twig\Operator\Binary\AbstractBinaryOperator; +use Twig\Operator\Unary\AbstractUnaryOperator; use Twig\RuntimeLoader\RuntimeLoaderInterface; use Twig\Source; use Twig\Token; @@ -307,8 +309,8 @@ public function testAddExtension() $this->assertArrayHasKey('foo_filter', $twig->getFilters()); $this->assertArrayHasKey('foo_function', $twig->getFunctions()); $this->assertArrayHasKey('foo_test', $twig->getTests()); - $this->assertArrayHasKey('foo_unary', $twig->getUnaryOperators()); - $this->assertArrayHasKey('foo_binary', $twig->getBinaryOperators()); + $this->assertNotNull($twig->getOperators()->getUnary('foo_unary')); + $this->assertNotNull($twig->getOperators()->getBinary('foo_binary')); $this->assertArrayHasKey('foo_global', $twig->getGlobals()); $visitors = $twig->getNodeVisitors(); $found = false; @@ -597,8 +599,38 @@ public function getFunctions(): array public function getOperators(): array { return [ - ['foo_unary' => ['precedence' => 0]], - ['foo_binary' => ['precedence' => 0]], + new class extends AbstractUnaryOperator { + public function getOperator(): string + { + return 'foo_unary'; + } + + public function getPrecedence(): int + { + return 0; + } + + public function getNodeClass(): string + { + return ''; + } + }, + new class extends AbstractBinaryOperator { + public function getOperator(): string + { + return 'foo_binary'; + } + + public function getPrecedence(): int + { + return 0; + } + + public function getNodeClass(): string + { + return ''; + } + }, ]; } diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php index d3887e93880..f98bade0841 100644 --- a/tests/ExpressionParserTest.php +++ b/tests/ExpressionParserTest.php @@ -28,6 +28,7 @@ use Twig\Node\Expression\Unary\AbstractUnary; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Node; +use Twig\Operator\Unary\AbstractUnaryOperator; use Twig\Parser; use Twig\Source; use Twig\TwigFilter; @@ -572,14 +573,31 @@ public function testUnaryPrecedenceChange() $env->addExtension(new class extends AbstractExtension { public function getOperators() { - $class = new class(new ConstantExpression('foo', 1), 1) extends AbstractUnary { - public function operator(Compiler $compiler): Compiler - { - return $compiler->raw('!'); - } - }; - - return [['!' => ['precedence' => 50, 'class' => $class::class]], []]; + return [ + new class extends AbstractUnaryOperator { + public function getOperator(): string + { + return '!'; + } + + public function getPrecedence(): int + { + return 50; + } + + public function getNodeClass(): string + { + $class = new class(new ConstantExpression('foo', 1), 1) extends AbstractUnary { + public function operator(Compiler $compiler): Compiler + { + return $compiler->raw('!'); + } + }; + + return $class::class; + } + }, + ]; } }); $parser = new Parser($env);