Skip to content

Commit 3d95ffe

Browse files
committed
feature #2996 Add "filter", "map", and "reduce" filters (fabpot)
This PR was squashed before being merged into the 1.x branch (closes #2996). Discussion ---------- Add "filter", "map", and "reduce" filters This PR adds support for 3 new filters: `filter`, `map`, and `reduce`. They take an arrow function as an argument (a PHP closure). I have restricted the usage of arrow functions as much as possible as it makes no sense to support them everywhere. So, for now, they are only accepted as arguments to filters (using them as arguments to function is not supported but could be easily added if we have a use case). The syntax is the following: `(x) => x + 3` where `(x)` is the list of arguments, `=>` starts the body, and the body is any Twig expression. Within the arrow function, the context is also available: `(x) => x + offset` works if `offset` is defined in the current context. ~~These new filters will allow us to deprecate the `if` support on the `for` tag, which does not work well with the `loop` variable:~~ ```twig {% set sizes = {xs: 34, s: 36, m: 38, l: 40, xl: 42} %} {# before #} {% for name, size in sizes if size < 38 %} {{ name }} = {{ size }} {% loop.last ? 'LAST' %} {# <--- works with this PR #} {% endfor %} {# after #} {% for name, size in sizes|filter(size => size < 38) %} {{ name }} = {{ size }} {{ loop.last ? 'LAST' }} {% endfor %} ``` This closes #2785 Commits ------- 5c15f89 added the key to the map and filter filters 13274bb added support for iterators 175041e removed fn in front of arrow functions dc27763 changed arrow syntax b30bce1 added "filter", "map", and "reduce" filters
2 parents 5d37a32 + 5c15f89 commit 3d95ffe

16 files changed

Lines changed: 422 additions & 8 deletions

File tree

CHANGELOG

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
* 1.41.0 (2019-XX-XX)
22

3+
* added "filter", "map", and "reduce" filters (and support for arrow functions)
34
* fixed partial output leak when a PHP fatal error occurs
45
* optimized context access on PHP 7.4
56

doc/filters/filter.rst

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
``filter``
2+
=========
3+
4+
.. versionadded:: 1.41
5+
The ``filter`` filter was added in Twig 1.41.
6+
7+
The ``filter`` filter filters elements of a sequence or a mapping using an arrow
8+
function. The arrow function receives the value of the sequence or mapping:
9+
10+
.. code-block:: twig
11+
12+
{% set sizes = [34, 36, 38, 40, 42] %}
13+
14+
{% for v in sizes|filter(v => v > 38) -%}
15+
{{ v }}
16+
{% endfor %}
17+
{# output 40 42 #}
18+
19+
{% set sizes = {
20+
xs: 34,
21+
s: 36,
22+
m: 38,
23+
l: 40,
24+
xl: 42,
25+
} %}
26+
27+
{% for k, v in sizes|filter(v => v > 38) -%}
28+
{{ k }} = {{ v }}
29+
{% endfor %}
30+
{# output l = 40 xl = 42 #}
31+
32+
The arrow function also receives the key as a second argument:
33+
34+
.. code-block:: twig
35+
36+
{% for k, v in sizes|filter((v, k) => v > 38 and k != "xl") -%}
37+
{{ k }} = {{ v }}
38+
{% endfor %}
39+
{# output l = 40 #}
40+
41+
Note that the arrow function has access to the current context.
42+
43+
Arguments
44+
---------
45+
46+
* ``array``: The sequence or mapping
47+
* ``arrow``: The arrow function

doc/filters/index.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Filters
1212
date_modify
1313
default
1414
escape
15+
filter
1516
first
1617
format
1718
join
@@ -20,10 +21,12 @@ Filters
2021
last
2122
length
2223
lower
24+
map
2325
merge
2426
nl2br
2527
number_format
2628
raw
29+
reduce
2730
replace
2831
reverse
2932
round

doc/filters/join.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ define it with the optional first parameter:
1919
2020
{{ [1, 2, 3]|join('|') }}
2121
{# outputs 1|2|3 #}
22-
22+
2323
A second parameter can also be provided that will be the separator used between
2424
the last two items of the sequence:
2525

2626
.. code-block:: twig
2727
2828
{{ [1, 2, 3]|join(', ', ' and ') }}
2929
{# outputs 1, 2 and 3 #}
30-
30+
3131
Arguments
3232
---------
3333

doc/filters/map.rst

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
``map``
2+
=======
3+
4+
.. versionadded:: 1.41
5+
The ``map`` filter was added in Twig 1.41.
6+
7+
The ``map`` filter applies an arrow function to the elements of a sequence or a
8+
mapping. The arrow function receives the value of the sequence or mapping:
9+
10+
.. code-block:: twig
11+
12+
{% set people = [
13+
{first: "Bob", last: "Smith"},
14+
{first: "Alice", last: "Dupond"},
15+
] %}
16+
17+
{{ people|map(p => "#{p.first} #{p.last}")|join(', ') }}
18+
{# outputs Bob Smith, Alice Dupond #}
19+
20+
The arrow function also receives the key as a second argument:
21+
22+
.. code-block:: twig
23+
24+
{% set people = {
25+
"Bob": "Smith",
26+
"Alice": "Dupond",
27+
} %}
28+
29+
{{ people|map((first, last) => "#{first} #{last}")|join(', ') }}
30+
{# outputs Bob Smith, Alice Dupond #}
31+
32+
Note that the arrow function has access to the current context.
33+
34+
Arguments
35+
---------
36+
37+
* ``array``: The sequence or mapping
38+
* ``arrow``: The arrow function

doc/filters/reduce.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
``reduce``
2+
=========
3+
4+
.. versionadded:: 1.41
5+
The ``reduce`` filter was added in Twig 1.41.
6+
7+
The ``reduce`` filter iteratively reduces a sequence or a mapping to a single
8+
value using an arrow function, so as to reduce it to a single value. The arrow
9+
function receives the return value of the previous iteration and the current
10+
value of the sequence or mapping:
11+
12+
.. code-block:: twig
13+
14+
{% set numbers = [1, 2, 3] %}
15+
16+
{{ numbers|reduce((carry, v) => carry + v) }}
17+
{# output 6 #}
18+
19+
The ``reduce`` filter takes an ``initial`` value as a second argument:
20+
21+
.. code-block:: twig
22+
23+
{{ numbers|reduce((carry, v) => carry + v, 10) }}
24+
{# output 16 #}
25+
26+
Note that the arrow function has access to the current context.
27+
28+
Arguments
29+
---------
30+
31+
* ``array``: The sequence or mapping
32+
* ``arrow``: The arrow function
33+
* ``initial``: The initial value

src/ExpressionParser.php

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
use Twig\Error\SyntaxError;
1616
use Twig\Node\Expression\ArrayExpression;
17+
use Twig\Node\Expression\ArrowFunctionExpression;
1718
use Twig\Node\Expression\AssignNameExpression;
1819
use Twig\Node\Expression\Binary\ConcatBinary;
1920
use Twig\Node\Expression\BlockReferenceExpression;
@@ -68,8 +69,12 @@ public function __construct(Parser $parser, $env = null)
6869
}
6970
}
7071

71-
public function parseExpression($precedence = 0)
72+
public function parseExpression($precedence = 0, $allowArrow = false)
7273
{
74+
if ($allowArrow && $arrow = $this->parseArrow()) {
75+
return $arrow;
76+
}
77+
7378
$expr = $this->getPrimary();
7479
$token = $this->parser->getCurrentToken();
7580
while ($this->isBinary($token) && $this->binaryOperators[$token->getValue()]['precedence'] >= $precedence) {
@@ -98,6 +103,64 @@ public function parseExpression($precedence = 0)
98103
return $expr;
99104
}
100105

106+
/**
107+
* @return ArrowFunctionExpression|null
108+
*/
109+
private function parseArrow()
110+
{
111+
$stream = $this->parser->getStream();
112+
113+
// short array syntax (one argument, no parentheses)?
114+
if ($stream->look(1)->test(Token::ARROW_TYPE)) {
115+
$line = $stream->getCurrent()->getLine();
116+
$token = $stream->expect(Token::NAME_TYPE);
117+
$names = [new AssignNameExpression($token->getValue(), $token->getLine())];
118+
$stream->expect(Token::ARROW_TYPE);
119+
120+
return new ArrowFunctionExpression($this->parseExpression(0), new Node($names), $line);
121+
}
122+
123+
// first, determine if we are parsing an arrow function by finding => (long form)
124+
$i = 0;
125+
if (!$stream->look($i)->test(Token::PUNCTUATION_TYPE, '(')) {
126+
return null;
127+
}
128+
++$i;
129+
while (true) {
130+
// variable name
131+
++$i;
132+
if (!$stream->look($i)->test(Token::PUNCTUATION_TYPE, ',')) {
133+
break;
134+
}
135+
++$i;
136+
}
137+
if (!$stream->look($i)->test(Token::PUNCTUATION_TYPE, ')')) {
138+
return null;
139+
}
140+
++$i;
141+
if (!$stream->look($i)->test(Token::ARROW_TYPE)) {
142+
return null;
143+
}
144+
145+
// yes, let's parse it properly
146+
$token = $stream->expect(Token::PUNCTUATION_TYPE, '(');
147+
$line = $token->getLine();
148+
149+
$names = [];
150+
while (true) {
151+
$token = $stream->expect(Token::NAME_TYPE);
152+
$names[] = new AssignNameExpression($token->getValue(), $token->getLine());
153+
154+
if (!$stream->nextIf(Token::PUNCTUATION_TYPE, ',')) {
155+
break;
156+
}
157+
}
158+
$stream->expect(Token::PUNCTUATION_TYPE, ')');
159+
$stream->expect(Token::ARROW_TYPE);
160+
161+
return new ArrowFunctionExpression($this->parseExpression(0), new Node($names), $line);
162+
}
163+
101164
protected function getPrimary()
102165
{
103166
$token = $this->parser->getCurrentToken();
@@ -499,7 +562,7 @@ public function parseFilterExpressionRaw($node, $tag = null)
499562
if (!$this->parser->getStream()->test(Token::PUNCTUATION_TYPE, '(')) {
500563
$arguments = new Node();
501564
} else {
502-
$arguments = $this->parseArguments(true);
565+
$arguments = $this->parseArguments(true, false, true);
503566
}
504567

505568
$class = $this->getFilterNodeClass($name->getAttribute('value'), $token->getLine());
@@ -526,7 +589,7 @@ public function parseFilterExpressionRaw($node, $tag = null)
526589
*
527590
* @throws SyntaxError
528591
*/
529-
public function parseArguments($namedArguments = false, $definition = false)
592+
public function parseArguments($namedArguments = false, $definition = false, $allowArrow = false)
530593
{
531594
$args = [];
532595
$stream = $this->parser->getStream();
@@ -541,7 +604,7 @@ public function parseArguments($namedArguments = false, $definition = false)
541604
$token = $stream->expect(Token::NAME_TYPE, null, 'An argument must be a name');
542605
$value = new NameExpression($token->getValue(), $this->parser->getCurrentToken()->getLine());
543606
} else {
544-
$value = $this->parseExpression();
607+
$value = $this->parseExpression(0, $allowArrow);
545608
}
546609

547610
$name = null;
@@ -558,7 +621,7 @@ public function parseArguments($namedArguments = false, $definition = false)
558621
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());
559622
}
560623
} else {
561-
$value = $this->parseExpression();
624+
$value = $this->parseExpression(0, $allowArrow);
562625
}
563626
}
564627

src/Extension/CoreExtension.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ public function getFilters()
194194
new TwigFilter('sort', 'twig_sort_filter'),
195195
new TwigFilter('merge', 'twig_array_merge'),
196196
new TwigFilter('batch', 'twig_array_batch'),
197+
new TwigFilter('filter', 'twig_array_filter'),
198+
new TwigFilter('map', 'twig_array_map'),
199+
new TwigFilter('reduce', 'twig_array_reduce'),
197200

198201
// string/array filters
199202
new TwigFilter('reverse', 'twig_reverse_filter', ['needs_environment' => true]),
@@ -1682,4 +1685,36 @@ function twig_array_batch($items, $size, $fill = null, $preserveKeys = true)
16821685

16831686
return $result;
16841687
}
1688+
1689+
function twig_array_filter($array, $arrow)
1690+
{
1691+
if (\is_array($array)) {
1692+
if (\PHP_VERSION_ID >= 50600) {
1693+
return array_filter($array, $arrow, \ARRAY_FILTER_USE_BOTH);
1694+
}
1695+
1696+
return array_filter($array, $arrow);
1697+
}
1698+
1699+
return new \CallbackFilterIterator($array, $arrow);
1700+
}
1701+
1702+
function twig_array_map($array, $arrow)
1703+
{
1704+
$r = [];
1705+
foreach ($array as $k => $v) {
1706+
$r[$k] = $arrow($v, $k);
1707+
}
1708+
1709+
return $r;
1710+
}
1711+
1712+
function twig_array_reduce($array, $arrow, $initial = null)
1713+
{
1714+
if (!\is_array($array)) {
1715+
$array = iterator_to_array($array);
1716+
}
1717+
1718+
return array_reduce($array, $arrow, $initial);
1719+
}
16851720
}

src/Lexer.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,8 +333,13 @@ protected function lexExpression()
333333
}
334334
}
335335

336+
// arrow function
337+
if ('=' === $this->code[$this->cursor] && '>' === $this->code[$this->cursor + 1]) {
338+
$this->pushToken(Token::ARROW_TYPE, '=>');
339+
$this->moveCursor('=>');
340+
}
336341
// operators
337-
if (preg_match($this->regexes['operator'], $this->code, $match, 0, $this->cursor)) {
342+
elseif (preg_match($this->regexes['operator'], $this->code, $match, 0, $this->cursor)) {
338343
$this->pushToken(Token::OPERATOR_TYPE, preg_replace('/\s+/', ' ', $match[0]));
339344
$this->moveCursor($match[0]);
340345
}

0 commit comments

Comments
 (0)