Skip to content

Commit

Permalink
Introduce operator classes to describe operators provided by extensio…
Browse files Browse the repository at this point in the history
…ns instead of arrays
  • Loading branch information
fabpot committed Jan 20, 2025
1 parent 4f3770e commit 4e4d23b
Show file tree
Hide file tree
Showing 58 changed files with 1,766 additions and 232 deletions.
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ jobs:
strategy:
matrix:
php-version:
- '8.0'
- '8.1'
- '8.2'
- '8.3'
Expand Down Expand Up @@ -68,7 +67,6 @@ jobs:
strategy:
matrix:
php-version:
- '8.0'
- '8.1'
- '8.2'
- '8.3'
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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
Expand Down
23 changes: 3 additions & 20 deletions doc/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~
Expand Down
23 changes: 23 additions & 0 deletions doc/deprecated.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]);
}
19 changes: 3 additions & 16 deletions src/Environment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -862,22 +861,10 @@ public function mergeGlobals(array $context): array

/**
* @internal
*
* @return array<string, array{precedence: int, precedence_change?: OperatorPrecedenceChange, class: class-string<AbstractUnary>}>
*/
public function getUnaryOperators(): array
{
return $this->extensionSet->getUnaryOperators();
}

/**
* @internal
*
* @return array<string, array{precedence: int, precedence_change?: OperatorPrecedenceChange, class: class-string<AbstractBinary>, associativity: ExpressionParser::OPERATOR_*}>
*/
public function getBinaryOperators(): array
public function getOperators(): Operators
{
return $this->extensionSet->getBinaryOperators();
return $this->extensionSet->getOperators();
}

private function updateOptionsHash(): void
Expand Down
98 changes: 39 additions & 59 deletions src/ExpressionParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,13 @@
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;
use Twig\Node\Expression\MacroReferenceExpression;
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;
Expand All @@ -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.
Expand All @@ -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<string, array{precedence: int, precedence_change?: OperatorPrecedenceChange, class: class-string<AbstractUnary>}> */
private $unaryOperators;
/** @var array<string, array{precedence: int, precedence_change?: OperatorPrecedenceChange, class: class-string<AbstractBinary>, 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;
}
}
}
Expand All @@ -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);

Expand All @@ -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()));
}
}
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/Extension/AbstractExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public function getFunctions()

public function getOperators()
{
return [[], []];
return [];
}

public function getLastModified(): int
Expand Down
Loading

0 comments on commit 4e4d23b

Please sign in to comment.