From b30bce1139e8d2a07c55b71c5ddefeba790d2bab Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 3 May 2019 08:28:45 +0200 Subject: [PATCH] added "filter", "map", and "reduce" filters --- CHANGELOG | 1 + doc/filters/filter.rst | 38 +++++++++++++ doc/filters/index.rst | 3 ++ doc/filters/join.rst | 4 +- doc/filters/map.rst | 26 +++++++++ doc/filters/reduce.rst | 33 ++++++++++++ src/ExpressionParser.php | 47 ++++++++++++++-- src/Extension/CoreExtension.php | 18 +++++++ src/Lexer.php | 7 ++- .../Expression/ArrowFunctionExpression.php | 53 +++++++++++++++++++ src/Token.php | 6 +++ test/Twig/Tests/Fixtures/filters/filter.test | 18 +++++++ test/Twig/Tests/Fixtures/filters/map.test | 18 +++++++ test/Twig/Tests/Fixtures/filters/reduce.test | 10 ++++ 14 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 doc/filters/filter.rst create mode 100644 doc/filters/map.rst create mode 100644 doc/filters/reduce.rst create mode 100644 src/Node/Expression/ArrowFunctionExpression.php create mode 100644 test/Twig/Tests/Fixtures/filters/filter.test create mode 100644 test/Twig/Tests/Fixtures/filters/map.test create mode 100644 test/Twig/Tests/Fixtures/filters/reduce.test diff --git a/CHANGELOG b/CHANGELOG index 8186871d5..d6bd7de72 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ * 1.41.0 (2019-XX-XX) + * added "filter", "map", and "reduce" filters (and support for arrow functions) * fixed partial output leak when a PHP fatal error occurs * optimized context access on PHP 7.4 diff --git a/doc/filters/filter.rst b/doc/filters/filter.rst new file mode 100644 index 000000000..7e6a132b1 --- /dev/null +++ b/doc/filters/filter.rst @@ -0,0 +1,38 @@ +``filter`` +========= + +.. versionadded:: 1.41 + The ``filter`` filter was added in Twig 1.41. + +The ``filter`` filter filters elements of a sequence or a mapping using an arrow +function. The arrow function receives the value of the sequence or mapping: + +.. code-block:: twig + + {% set sizes = [34, 36, 38, 40, 42] %} + + {% for v in sizes|filter(|v| => v > 38) -%} + {{ v }} + {% endfor %} + {# output 40 42 #} + + {% set sizes = { + xs: 34, + s: 36, + m: 38, + l: 40, + xl: 42, + } %} + + {% for k, v in sizes|filter(|v| => v > 38) -%} + {{ k }} = {{ v }} + {% endfor %} + {# output l = 40 xl = 42 #} + +Note that the arrow function has access to the current context. + +Arguments +--------- + +* ``array``: The sequence or mapping +* ``arrow``: The arrow function diff --git a/doc/filters/index.rst b/doc/filters/index.rst index 1ef94199f..8524fc03c 100644 --- a/doc/filters/index.rst +++ b/doc/filters/index.rst @@ -12,6 +12,7 @@ Filters date_modify default escape + filter first format join @@ -20,10 +21,12 @@ Filters last length lower + map merge nl2br number_format raw + reduce replace reverse round diff --git a/doc/filters/join.rst b/doc/filters/join.rst index 4e97bb73c..3f9079eab 100644 --- a/doc/filters/join.rst +++ b/doc/filters/join.rst @@ -19,7 +19,7 @@ define it with the optional first parameter: {{ [1, 2, 3]|join('|') }} {# outputs 1|2|3 #} - + A second parameter can also be provided that will be the separator used between the last two items of the sequence: @@ -27,7 +27,7 @@ the last two items of the sequence: {{ [1, 2, 3]|join(', ', ' and ') }} {# outputs 1, 2 and 3 #} - + Arguments --------- diff --git a/doc/filters/map.rst b/doc/filters/map.rst new file mode 100644 index 000000000..0800e7c30 --- /dev/null +++ b/doc/filters/map.rst @@ -0,0 +1,26 @@ +``map`` +======= + +.. versionadded:: 1.41 + The ``map`` filter was added in Twig 1.41. + +The ``map`` filter applies an arrow function to the elements of a sequence or a +mapping. The arrow function receives the value of the sequence or mapping: + +.. code-block:: twig + + {% set people = [ + {first: "Bob", last: "Smith"}, + {first: "Alice", last: "Dupond"}, + ] %} + + {{ people|map(|p| => "#{p.first} #{p.last}")|join(', ') }} + {# outputs Bob Smith, Alice Dupond #} + +Note that the arrow function has access to the current context. + +Arguments +--------- + +* ``array``: The sequence or mapping +* ``arrow``: The arrow function diff --git a/doc/filters/reduce.rst b/doc/filters/reduce.rst new file mode 100644 index 000000000..7f0108efd --- /dev/null +++ b/doc/filters/reduce.rst @@ -0,0 +1,33 @@ +``reduce`` +========= + +.. versionadded:: 1.41 + The ``reduce`` filter was added in Twig 1.41. + +The ``reduce`` filter iteratively reduces a sequence or a mapping to a single +value using an arrow function, so as to reduce it to a single value. The arrow +function receives the return value of the previous iteration and the current +value of the sequence or mapping: + +.. code-block:: twig + + {% set numbers = [1, 2, 3] %} + + {{ numbers|reduce(|carry, v| => carry + v) }} + {# output 6 #} + +The ``reduce`` filter takes an ``initial`` value as a second argument: + +.. code-block:: twig + + {{ numbers|reduce(|carry, v| => carry + v, 10) }} + {# output 16 #} + +Note that the arrow function has access to the current context. + +Arguments +--------- + +* ``array``: The sequence or mapping +* ``arrow``: The arrow function +* ``initial``: The initial value diff --git a/src/ExpressionParser.php b/src/ExpressionParser.php index f8a84feea..e2f952516 100644 --- a/src/ExpressionParser.php +++ b/src/ExpressionParser.php @@ -14,6 +14,7 @@ namespace Twig; use Twig\Error\SyntaxError; use Twig\Node\Expression\ArrayExpression; +use Twig\Node\Expression\ArrowFunctionExpression; use Twig\Node\Expression\AssignNameExpression; use Twig\Node\Expression\Binary\ConcatBinary; use Twig\Node\Expression\BlockReferenceExpression; @@ -68,8 +69,12 @@ class ExpressionParser } } - public function parseExpression($precedence = 0) + public function parseExpression($precedence = 0, $allowArrow = false) { + if ($allowArrow && $arrow = $this->parseArrow()) { + return $arrow; + } + $expr = $this->getPrimary(); $token = $this->parser->getCurrentToken(); while ($this->isBinary($token) && $this->binaryOperators[$token->getValue()]['precedence'] >= $precedence) { @@ -98,6 +103,38 @@ class ExpressionParser return $expr; } + /** + * @return ArrowFunctionExpression|null + */ + private function parseArrow() + { + $stream = $this->parser->getStream(); + $token = $this->parser->getCurrentToken(); + $line = $token->getLine(); + if (!$token->test(Token::PUNCTUATION_TYPE, '|')) { + return null; + } + + $stream->next(); + $names = []; + while (true) { + $token = $this->parser->getCurrentToken(); + if (!$token->test(Token::NAME_TYPE)) { + throw new SyntaxError(sprintf('Unexpected token "%s" of value "%s".', Token::typeToEnglish($token->getType()), $token->getValue()), $token->getLine(), $stream->getSourceContext()); + } + $names[] = $token->getValue(); + $stream->next(); + if (!$stream->nextIf(Token::PUNCTUATION_TYPE, ',')) { + break; + } + } + + $stream->expect(Token::PUNCTUATION_TYPE, '|'); + $stream->expect(Token::ARROW_TYPE); + + return new ArrowFunctionExpression($this->parseExpression(0), $names, $line); + } + protected function getPrimary() { $token = $this->parser->getCurrentToken(); @@ -499,7 +536,7 @@ class ExpressionParser if (!$this->parser->getStream()->test(Token::PUNCTUATION_TYPE, '(')) { $arguments = new Node(); } else { - $arguments = $this->parseArguments(true); + $arguments = $this->parseArguments(true, false, true); } $class = $this->getFilterNodeClass($name->getAttribute('value'), $token->getLine()); @@ -526,7 +563,7 @@ class ExpressionParser * * @throws SyntaxError */ - public function parseArguments($namedArguments = false, $definition = false) + public function parseArguments($namedArguments = false, $definition = false, $allowArrow = false) { $args = []; $stream = $this->parser->getStream(); @@ -541,7 +578,7 @@ class ExpressionParser $token = $stream->expect(Token::NAME_TYPE, null, 'An argument must be a name'); $value = new NameExpression($token->getValue(), $this->parser->getCurrentToken()->getLine()); } else { - $value = $this->parseExpression(); + $value = $this->parseExpression(0, $allowArrow); } $name = null; @@ -558,7 +595,7 @@ class ExpressionParser throw new SyntaxError(sprintf('A default value for an argument must be a constant (a boolean, a string, a number, or an array).'), $token->getLine(), $stream->getSourceContext()); } } else { - $value = $this->parseExpression(); + $value = $this->parseExpression(0, $allowArrow); } } diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index b0362a70d..94353e8fb 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -194,6 +194,9 @@ class CoreExtension extends AbstractExtension new TwigFilter('sort', 'twig_sort_filter'), new TwigFilter('merge', 'twig_array_merge'), new TwigFilter('batch', 'twig_array_batch'), + new TwigFilter('filter', 'twig_array_filter'), + new TwigFilter('map', 'twig_array_map'), + new TwigFilter('reduce', 'twig_array_reduce'), // string/array filters new TwigFilter('reverse', 'twig_reverse_filter', ['needs_environment' => true]), @@ -1682,4 +1685,19 @@ function twig_array_batch($items, $size, $fill = null, $preserveKeys = true) return $result; } + +function twig_array_filter($array, $arrow) +{ + return array_filter($array, $arrow); +} + +function twig_array_map($array, $arrow) +{ + return array_map($arrow, $array); +} + +function twig_array_reduce($array, $arrow, $initial = null) +{ + return array_reduce($array, $arrow, $initial); +} } diff --git a/src/Lexer.php b/src/Lexer.php index 81f3b5340..7cead8340 100644 --- a/src/Lexer.php +++ b/src/Lexer.php @@ -333,8 +333,13 @@ class Lexer implements \Twig_LexerInterface } } + // arrow function + if ('=' === $this->code[$this->cursor] && '>' === $this->code[$this->cursor + 1]) { + $this->pushToken(Token::ARROW_TYPE, '=>'); + $this->moveCursor('=>'); + } // operators - if (preg_match($this->regexes['operator'], $this->code, $match, 0, $this->cursor)) { + elseif (preg_match($this->regexes['operator'], $this->code, $match, 0, $this->cursor)) { $this->pushToken(Token::OPERATOR_TYPE, preg_replace('/\s+/', ' ', $match[0])); $this->moveCursor($match[0]); } diff --git a/src/Node/Expression/ArrowFunctionExpression.php b/src/Node/Expression/ArrowFunctionExpression.php new file mode 100644 index 000000000..c4f6324d7 --- /dev/null +++ b/src/Node/Expression/ArrowFunctionExpression.php @@ -0,0 +1,53 @@ + + */ +class ArrowFunctionExpression extends AbstractExpression +{ + public function __construct(AbstractExpression $expr, array $names, $lineno, $tag = null) + { + parent::__construct(['expr' => $expr], ['names' => $names], $lineno, $tag); + } + + public function compile(Compiler $compiler) + { + $compiler + ->addDebugInfo($this) + ->raw('function (') + ; + foreach ($this->getAttribute('names') as $i => $name) { + if ($i) { + $compiler->raw(', '); + } + + $compiler->raw('$__'.$name.'__'); + } + $compiler + ->raw(') use ($context) { ') + ; + foreach ($this->getAttribute('names') as $name) { + $compiler->raw('$context["'.$name.'"] = $__'.$name.'__; '); + } + $compiler + ->raw('return ') + ->subcompile($this->getNode('expr')) + ->raw('; }') + ; + } +} diff --git a/src/Token.php b/src/Token.php index 933897299..a0bb11af1 100644 --- a/src/Token.php +++ b/src/Token.php @@ -38,6 +38,7 @@ class Token const PUNCTUATION_TYPE = 9; const INTERPOLATION_START_TYPE = 10; const INTERPOLATION_END_TYPE = 11; + const ARROW_TYPE = 12; /** * @param int $type The type of the token @@ -157,6 +158,9 @@ class Token case self::INTERPOLATION_END_TYPE: $name = 'INTERPOLATION_END_TYPE'; break; + case self::ARROW_TYPE: + $name = 'ARROW_TYPE'; + break; default: throw new \LogicException(sprintf('Token of type "%s" does not exist.', $type)); } @@ -200,6 +204,8 @@ class Token return 'begin of string interpolation'; case self::INTERPOLATION_END_TYPE: return 'end of string interpolation'; + case self::ARROW_TYPE: + return 'arrow function'; default: throw new \LogicException(sprintf('Token of type "%s" does not exist.', $type)); } diff --git a/test/Twig/Tests/Fixtures/filters/filter.test b/test/Twig/Tests/Fixtures/filters/filter.test new file mode 100644 index 000000000..1f5dba248 --- /dev/null +++ b/test/Twig/Tests/Fixtures/filters/filter.test @@ -0,0 +1,18 @@ +--TEST-- +"filter" filter +--TEMPLATE-- +{% set offset = 3 %} + +{% for k, v in [1, 5, 3, 4, 5]|filter(|v| => v > offset) -%} + {{ k }} = {{ v }} +{% endfor %} +{% for k, v in {a: 1, b: 2, c: 5, d: 2}|filter(|v| => v > offset) -%} + {{ k }} = {{ v }} +{% endfor %} +--DATA-- +return [] +--EXPECT-- +1 = 5 +3 = 4 +4 = 5 +c = 5 diff --git a/test/Twig/Tests/Fixtures/filters/map.test b/test/Twig/Tests/Fixtures/filters/map.test new file mode 100644 index 000000000..9df033b35 --- /dev/null +++ b/test/Twig/Tests/Fixtures/filters/map.test @@ -0,0 +1,18 @@ +--TEST-- +"map" filter +--TEMPLATE-- +{% set offset = 3 %} + +{% for k, v in [1, 2]|map(|item| => item + 2 ) -%} + {{ k }} = {{ v }} +{% endfor %} +{% for k, v in {a: 1, b: 2}|map(|item| => item ~ "*" ) -%} + {{ k }} = {{ v }} +{% endfor %} +--DATA-- +return [] +--EXPECT-- +0 = 3 +1 = 4 +a = 1* +b = 2* diff --git a/test/Twig/Tests/Fixtures/filters/reduce.test b/test/Twig/Tests/Fixtures/filters/reduce.test new file mode 100644 index 000000000..d6f4c0384 --- /dev/null +++ b/test/Twig/Tests/Fixtures/filters/reduce.test @@ -0,0 +1,10 @@ +--TEST-- +"reduce" filter +--TEMPLATE-- +{% set offset = 3 %} + +{{ [1, -1, 4]|reduce(|carry, item| => carry + item + offset, 10) }} +--DATA-- +return [] +--EXPECT-- +23