diff --git a/CHANGELOG b/CHANGELOG index cb8680494..530084a96 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -224,6 +224,8 @@ * 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 * 1.40.1 (2019-04-29) diff --git a/doc/filters/filter.rst b/doc/filters/filter.rst new file mode 100644 index 000000000..a6c1c132d --- /dev/null +++ b/doc/filters/filter.rst @@ -0,0 +1,47 @@ +``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 #} + +The arrow function also receives the key as a second argument: + +.. code-block:: twig + + {% for k, v in sizes|filter((v, k) => v > 38 and k != "xl") -%} + {{ k }} = {{ v }} + {% endfor %} + {# output l = 40 #} + +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 92abc331f..f3468f683 100644 --- a/doc/filters/index.rst +++ b/doc/filters/index.rst @@ -13,6 +13,7 @@ Filters date_modify default escape + filter first format join @@ -21,10 +22,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 1e8c8b215..a9ea147e4 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..b4849a629 --- /dev/null +++ b/doc/filters/map.rst @@ -0,0 +1,38 @@ +``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 #} + +The arrow function also receives the key as a second argument: + +.. code-block:: twig + + {% set people = { + "Bob": "Smith", + "Alice": "Dupond", + } %} + + {{ people|map((first, last) => "#{first} #{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..10a0d5acb --- /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 18eecf414..73cd27b52 100644 --- a/src/ExpressionParser.php +++ b/src/ExpressionParser.php @@ -15,6 +15,7 @@ namespace Twig; use Twig\Error\SyntaxError; use Twig\Node\Expression\AbstractExpression; 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; @@ -60,8 +61,12 @@ class ExpressionParser $this->binaryOperators = $env->getBinaryOperators(); } - 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) { @@ -90,6 +95,64 @@ class ExpressionParser return $expr; } + /** + * @return ArrowFunctionExpression|null + */ + private function parseArrow() + { + $stream = $this->parser->getStream(); + + // short array syntax (one argument, no parentheses)? + if ($stream->look(1)->test(Token::ARROW_TYPE)) { + $line = $stream->getCurrent()->getLine(); + $token = $stream->expect(Token::NAME_TYPE); + $names = [new AssignNameExpression($token->getValue(), $token->getLine())]; + $stream->expect(Token::ARROW_TYPE); + + return new ArrowFunctionExpression($this->parseExpression(0), new Node($names), $line); + } + + // first, determine if we are parsing an arrow function by finding => (long form) + $i = 0; + if (!$stream->look($i)->test(Token::PUNCTUATION_TYPE, '(')) { + return null; + } + ++$i; + while (true) { + // variable name + ++$i; + if (!$stream->look($i)->test(Token::PUNCTUATION_TYPE, ',')) { + break; + } + ++$i; + } + if (!$stream->look($i)->test(Token::PUNCTUATION_TYPE, ')')) { + return null; + } + ++$i; + if (!$stream->look($i)->test(Token::ARROW_TYPE)) { + return null; + } + + // yes, let's parse it properly + $token = $stream->expect(Token::PUNCTUATION_TYPE, '('); + $line = $token->getLine(); + + $names = []; + while (true) { + $token = $stream->expect(Token::NAME_TYPE); + $names[] = new AssignNameExpression($token->getValue(), $token->getLine()); + + if (!$stream->nextIf(Token::PUNCTUATION_TYPE, ',')) { + break; + } + } + $stream->expect(Token::PUNCTUATION_TYPE, ')'); + $stream->expect(Token::ARROW_TYPE); + + return new ArrowFunctionExpression($this->parseExpression(0), new Node($names), $line); + } + private function getPrimary(): AbstractExpression { $token = $this->parser->getCurrentToken(); @@ -485,7 +548,7 @@ class ExpressionParser if (!$this->parser->getStream()->test(/* Token::PUNCTUATION_TYPE */ 9, '(')) { $arguments = new Node(); } else { - $arguments = $this->parseArguments(true); + $arguments = $this->parseArguments(true, false, true); } $class = $this->getFilterNodeClass($name->getAttribute('value'), $token->getLine()); @@ -512,7 +575,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(); @@ -527,7 +590,7 @@ class ExpressionParser $token = $stream->expect(/* Token::NAME_TYPE */ 5, 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; @@ -544,7 +607,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); } } @@ -613,7 +676,7 @@ class ExpressionParser $class = $this->getTestNodeClass($test); $arguments = null; if ($stream->test(/* Token::PUNCTUATION_TYPE */ 9, '(')) { - $arguments = $this->parser->getExpressionParser()->parseArguments(true); + $arguments = $this->parseArguments(true); } return new $class($node, $name, $arguments, $this->parser->getCurrentToken()->getLine()); diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index d55bb165f..d0b31112a 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -229,6 +229,9 @@ final class CoreExtension extends AbstractExtension new TwigFilter('merge', 'twig_array_merge'), new TwigFilter('batch', 'twig_array_batch'), new TwigFilter('column', 'twig_array_column'), + 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]), @@ -1728,4 +1731,36 @@ function twig_array_column($array, $name): array return array_column($array, $name); } + +function twig_array_filter($array, $arrow) +{ + if (\is_array($array)) { + if (\PHP_VERSION_ID >= 50600) { + return array_filter($array, $arrow, \ARRAY_FILTER_USE_BOTH); + } + + return array_filter($array, $arrow); + } + + return new \CallbackFilterIterator($array, $arrow); +} + +function twig_array_map($array, $arrow) +{ + $r = []; + foreach ($array as $k => $v) { + $r[$k] = $arrow($v, $k); + } + + return $r; +} + +function twig_array_reduce($array, $arrow, $initial = null) +{ + if (!\is_array($array)) { + $array = iterator_to_array($array); + } + + return array_reduce($array, $arrow, $initial); +} } diff --git a/src/Extension/DebugExtension.php b/src/Extension/DebugExtension.php index 2e8510dfb..cd3edacaa 100644 --- a/src/Extension/DebugExtension.php +++ b/src/Extension/DebugExtension.php @@ -46,7 +46,7 @@ function twig_var_dump(Environment $env, $context, ...$vars) return; } - ob_start(); + ob_start(function () { return ''; }); if (!$vars) { $vars = []; diff --git a/src/Lexer.php b/src/Lexer.php index 649780606..dbb076839 100644 --- a/src/Lexer.php +++ b/src/Lexer.php @@ -304,8 +304,13 @@ class Lexer } } + // 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 */ 8, 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..36b77da86 --- /dev/null +++ b/src/Node/Expression/ArrowFunctionExpression.php @@ -0,0 +1,64 @@ + + */ +class ArrowFunctionExpression extends AbstractExpression +{ + public function __construct(AbstractExpression $expr, Node $names, $lineno, $tag = null) + { + parent::__construct(['expr' => $expr, 'names' => $names], [], $lineno, $tag); + } + + public function compile(Compiler $compiler) + { + $compiler + ->addDebugInfo($this) + ->raw('function (') + ; + foreach ($this->getNode('names') as $i => $name) { + if ($i) { + $compiler->raw(', '); + } + + $compiler + ->raw('$__') + ->raw($name->getAttribute('name')) + ->raw('__') + ; + } + $compiler + ->raw(') use ($context) { ') + ; + foreach ($this->getNode('names') as $name) { + $compiler + ->raw('$context["') + ->raw($name->getAttribute('name')) + ->raw('"] = $__') + ->raw($name->getAttribute('name')) + ->raw('__; ') + ; + } + $compiler + ->raw('return ') + ->subcompile($this->getNode('expr')) + ->raw('; }') + ; + } +} diff --git a/src/Node/MacroNode.php b/src/Node/MacroNode.php index dd887e6ee..8e927a51d 100644 --- a/src/Node/MacroNode.php +++ b/src/Node/MacroNode.php @@ -90,7 +90,7 @@ class MacroNode extends Node ->outdent() ->write("]);\n\n") ->write("\$blocks = [];\n\n") - ->write("ob_start();\n") + ->write("ob_start(function () { return ''; });\n") ->write("try {\n") ->indent() ->subcompile($this->getNode('body')) diff --git a/src/Node/SetNode.php b/src/Node/SetNode.php index 9e1668a46..f7dbf686c 100644 --- a/src/Node/SetNode.php +++ b/src/Node/SetNode.php @@ -58,7 +58,7 @@ class SetNode extends Node implements NodeCaptureInterface } else { if ($this->getAttribute('capture')) { $compiler - ->write("ob_start();\n") + ->write("ob_start(function () { return ''; });\n") ->subcompile($this->getNode('values')) ; } diff --git a/src/Node/SpacelessNode.php b/src/Node/SpacelessNode.php index ba951c425..07c3107e6 100644 --- a/src/Node/SpacelessNode.php +++ b/src/Node/SpacelessNode.php @@ -33,7 +33,7 @@ class SpacelessNode extends Node implements NodeOutputInterface { $compiler ->addDebugInfo($this) - ->write("ob_start();\n") + ->write("ob_start(function () { return ''; });\n") ->subcompile($this->getNode('body')) ->write("echo trim(preg_replace('/>\s+<', ob_get_clean()));\n") ; diff --git a/src/Template.php b/src/Template.php index b54838d32..ed680a42c 100644 --- a/src/Template.php +++ b/src/Template.php @@ -223,7 +223,7 @@ abstract class Template */ public function renderParentBlock($name, array $context, array $blocks = []) { - ob_start(); + ob_start(function () { return ''; }); $this->displayParentBlock($name, $context, $blocks); return ob_get_clean(); @@ -244,7 +244,7 @@ abstract class Template */ public function renderBlock($name, array $context, array $blocks = [], $useBlocks = true) { - ob_start(); + ob_start(function () { return ''; }); $this->displayBlock($name, $context, $blocks, $useBlocks); return ob_get_clean(); @@ -375,7 +375,7 @@ abstract class Template public function render(array $context) { $level = ob_get_level(); - ob_start(); + ob_start(function () { return ''; }); try { $this->display($context); } catch (\Throwable $e) { diff --git a/src/TemplateWrapper.php b/src/TemplateWrapper.php index 5a5fec267..8dea3ac08 100644 --- a/src/TemplateWrapper.php +++ b/src/TemplateWrapper.php @@ -92,7 +92,7 @@ final class TemplateWrapper { $context = $this->env->mergeGlobals($context); $level = ob_get_level(); - ob_start(); + ob_start(function () { return ''; }); try { $this->template->displayBlock($name, $context); } catch (\Throwable $e) { diff --git a/src/Token.php b/src/Token.php index ee452d67a..262fa48db 100644 --- a/src/Token.php +++ b/src/Token.php @@ -36,6 +36,7 @@ final 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 @@ -155,6 +156,9 @@ final 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)); } @@ -198,6 +202,8 @@ final 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/expressions/not_arrow_fn.test b/test/Twig/Tests/Fixtures/expressions/not_arrow_fn.test new file mode 100644 index 000000000..af82c47cf --- /dev/null +++ b/test/Twig/Tests/Fixtures/expressions/not_arrow_fn.test @@ -0,0 +1,8 @@ +--TEST-- +A string in parentheses cannot be confused with an arrow function +--TEMPLATE-- +{{ ["foo", "bar"]|join(("f")) }} +--DATA-- +return [] +--EXPECT-- +foofbar diff --git a/test/Twig/Tests/Fixtures/filters/filter.test b/test/Twig/Tests/Fixtures/filters/filter.test new file mode 100644 index 000000000..1d6a96be2 --- /dev/null +++ b/test/Twig/Tests/Fixtures/filters/filter.test @@ -0,0 +1,36 @@ +--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: 8}|filter(v => v > offset) -%} + {{ k }} = {{ v }} +{% endfor %} + +{% for k, v in [1, 5, 3, 4, 5]|filter(v => v > offset) -%} + {{ k }} = {{ v }} +{% endfor %} + +{% for k, v in it|filter((v) => v > offset) -%} + {{ k }} = {{ v }} +{% endfor %} +--DATA-- +return ['it' => new \ArrayIterator(['a' => 1, 'b' => 2, 'c' => 5, 'd' => 8])] +--EXPECT-- +1 = 5 +3 = 4 +4 = 5 + +c = 5 +d = 8 + +1 = 5 +3 = 4 +4 = 5 + +c = 5 +d = 8 diff --git a/test/Twig/Tests/Fixtures/filters/filter_php_56.test b/test/Twig/Tests/Fixtures/filters/filter_php_56.test new file mode 100644 index 000000000..05711e981 --- /dev/null +++ b/test/Twig/Tests/Fixtures/filters/filter_php_56.test @@ -0,0 +1,20 @@ +--TEST-- +"filter" filter (PHP 5.6 required) +--CONDITION-- +version_compare(phpversion(), '5.6.0', '>=') +--TEMPLATE-- +{% set offset = 3 %} + +{% for k, v in {a: 1, b: 2, c: 5, d: 8}|filter((v, k) => (v > offset) and (k != "d")) -%} + {{ k }} = {{ v }} +{% endfor %} + +{% for k, v in it|filter((v, k) => (v > offset) and (k != "d")) -%} + {{ k }} = {{ v }} +{% endfor %} +--DATA-- +return ['it' => new \ArrayIterator(['a' => 1, 'b' => 2, 'c' => 5, 'd' => 8])] +--EXPECT-- +c = 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..5552f8166 --- /dev/null +++ b/test/Twig/Tests/Fixtures/filters/map.test @@ -0,0 +1,41 @@ +--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 %} + +{% for k, v in {a: 1, b: 2}|map((item, k) => item ~ "*" ~ k ) -%} + {{ k }} = {{ v }} +{% endfor %} + +{% for k, v in [1, 2]|map(item => item + 2 ) -%} + {{ k }} = {{ v }} +{% endfor %} + +{% for k, v in it|map(item => item + 2 ) -%} + {{ k }} = {{ v }} +{% endfor %} +--DATA-- +return ['it' => new \ArrayIterator([1, 2])] +--EXPECT-- +0 = 3 +1 = 4 + +a = 1* +b = 2* + +a = 1*a +b = 2*b + +0 = 3 +1 = 4 + +0 = 3 +1 = 4 diff --git a/test/Twig/Tests/Fixtures/filters/reduce.test b/test/Twig/Tests/Fixtures/filters/reduce.test new file mode 100644 index 000000000..73cad4168 --- /dev/null +++ b/test/Twig/Tests/Fixtures/filters/reduce.test @@ -0,0 +1,14 @@ +--TEST-- +"reduce" filter +--TEMPLATE-- +{% set offset = 3 %} + +{{ [1, -1, 4]|reduce((carry, item) => carry + item + offset, 10) }} + +{{ it|reduce((carry, item) => carry + item + offset, 10) }} +--DATA-- +return ['it' => new \ArrayIterator([1, -1, 4])] +--EXPECT-- +23 + +23 diff --git a/test/Twig/Tests/Node/MacroTest.php b/test/Twig/Tests/Node/MacroTest.php index 855364b0d..1dda543cd 100644 --- a/test/Twig/Tests/Node/MacroTest.php +++ b/test/Twig/Tests/Node/MacroTest.php @@ -51,7 +51,7 @@ public function macro_foo(\$__foo__ = null, \$__bar__ = "Foo", ...\$__varargs__) \$blocks = []; - ob_start(); + ob_start(function () { return ''; }); try { echo "foo"; diff --git a/test/Twig/Tests/Node/SetTest.php b/test/Twig/Tests/Node/SetTest.php index 4f6b94271..ff4e43e47 100644 --- a/test/Twig/Tests/Node/SetTest.php +++ b/test/Twig/Tests/Node/SetTest.php @@ -49,7 +49,7 @@ EOF $node = new SetNode(true, $names, $values, 1); $tests[] = [$node, <<env->getCharset()); EOF diff --git a/test/Twig/Tests/Node/SpacelessTest.php b/test/Twig/Tests/Node/SpacelessTest.php index c0b7d526a..13f400fd3 100644 --- a/test/Twig/Tests/Node/SpacelessTest.php +++ b/test/Twig/Tests/Node/SpacelessTest.php @@ -32,7 +32,7 @@ class Twig_Tests_Node_SpacelessTest extends NodeTestCase return [ [$node, <<
foo
"; echo trim(preg_replace('/>\s+<', ob_get_clean())); EOF