Skip to content

Commit

Permalink
[FEATURE] Array unpacking (spread operator)
Browse files Browse the repository at this point in the history
This change adds unpacking to Fluid’s array syntax, also known as the
spread operator. Internally, it uses PHP’s own spread operator, so the
behavior should be consistent between Fluid and PHP.

Example:

<f:variable name="array1" value="{key1: 'value1'}" />
<f:variable name="array2" value="{key2: 'value2'}" />
<f:variable name="combinedArray" value="{...array1, ...array2, anotherKey: 'another value'}" />

Result:

{key1: 'value1', key2: 'value2', anotherKey: 'another value'}

Note that this change does not cover dynamic ViewHelper arguments.
This means that you can only use the spread operator in normal array
contexts, but not for arguments in inline ViewHelper syntax. This is
currently not possible because then ViewHelper arguments could then
only be validated at runtime rather than parsetime, which is where
validation currently happens for performance reasons.

Because of similarities between object accessor and array definition
syntax, there is an edge case where Fluid wrongly chooses object
accessor instead of array syntax. This only happens if the array
syntax is used without any spaces at the beginning and only with a
single spread operator. This case shouldn’t be relevant for real-world
usage because it only creates a copy of the original array.

This edge case results in null:

{...input1}

These variants work fine:

{ ...input1}
{ ...input1 }
{...input1, ...input2}
{key: value, ...input1}
  • Loading branch information
s2b committed Jul 5, 2023
1 parent e308c61 commit 7f826b7
Show file tree
Hide file tree
Showing 5 changed files with 233 additions and 43 deletions.
89 changes: 53 additions & 36 deletions src/Core/Parser/Patterns.php
Original file line number Diff line number Diff line change
Expand Up @@ -201,26 +201,34 @@ abstract class Patterns
* THIS IS ALMOST THE SAME AS IN SCAN_PATTERN_SHORTHANDSYNTAX_OBJECTACCESSORS
*/
public static $SCAN_PATTERN_SHORTHANDSYNTAX_ARRAYS = '/^
(?P<Recursion> # Start the recursive part of the regular expression - describing the array syntax
{ # Each array needs to start with {
(?P<Array> # Start sub-match
(?P<Recursion> # Start the recursive part of the regular expression - describing the array syntax
{ # Each array needs to start with {
(?P<Array> # Start sub-match
(?:
\s*(
[a-zA-Z0-9\\-_]+ # Unquoted key
|"(?:\\\"|[^"])+" # Double quoted key, supporting more characters like dots and square brackets
|\'(?:\\\\\'|[^\'])+\' # Single quoted key, supporting more characters like dots and square brackets
(?:
\s*(
[a-zA-Z0-9\\-_]+ # Unquoted key
|"(?:\\\"|[^"])+" # Double quoted key, supporting more characters like dots and square brackets
|\'(?:\\\\\'|[^\'])+\' # Single quoted key, supporting more characters like dots and square brackets
)
\s*[:=]\s* # Key|Value delimiter : or =
(?: # Possible value options:
"(?:\\\"|[^"])*" # Double quoted string
|\'(?:\\\\\'|[^\'])*\' # Single quoted string
|[a-zA-Z0-9\-_.]+ # variable identifiers
|(?P>Recursion) # Another sub-array
) # END possible value options
\s*,?\s* # There might be a , to separate different parts of the array
)
|(?: # Array unpacking (spread operator)
\s*
\.{3}\s*(?:(?=[^,{}\.]*[a-zA-Z])[a-zA-Z0-9_-]*)
(?:\\.[a-zA-Z0-9_-]+)*
\s*,?\s* # There might be a , to separate different parts of the array
)
\s*[:=]\s* # Key|Value delimiter : or =
(?: # Possible value options:
"(?:\\\"|[^"])*" # Double quoted string
|\'(?:\\\\\'|[^\'])*\' # Single quoted string
|[a-zA-Z0-9\-_.]+ # variable identifiers
|(?P>Recursion) # Another sub-array
) # END possible value options
\s*,?\s* # There might be a , to separate different parts of the array
)* # The above cycle is repeated for all array elements
) # End array sub-match
} # Each array ends with }
)* # The above cycle is repeated for all array elements
) # End array sub-match
} # Each array ends with }
)$/x';

/**
Expand All @@ -229,25 +237,34 @@ abstract class Patterns
* Note that this pattern can be used on strings with or without surrounding curly brackets.
*/
public static $SPLIT_PATTERN_SHORTHANDSYNTAX_ARRAY_PARTS = '/
(?P<ArrayPart> # Start sub-match of one key and value pair
(?P<Key> # The arry key
[a-zA-Z0-9_-]+ # Unquoted
|"(?:\\\\"|[^"])+" # Double quoted
|\'(?:\\\\\'|[^\'])+\' # Single quoted
)
\\s*[:=]\\s* # Key|Value delimiter : or =
(?: # BEGIN Possible value options
(?P<QuotedString> # Quoted string
"(?:\\\\"|[^"])*"
|\'(?:\\\\\'|[^\'])*\'
(?P<ArrayPart> # Start sub-match of one key and value pair
(?:
(?P<Key> # The arry key
[a-zA-Z0-9_-]+ # Unquoted
|"(?:\\\\"|[^"])+" # Double quoted
|\'(?:\\\\\'|[^\'])+\' # Single quoted
)
|(?P<VariableIdentifier>
(?:(?=[^,{}\.]*[a-zA-Z])[a-zA-Z0-9_-]*) # variable identifiers must contain letters (otherwise they are hardcoded numbers)
(?:\\.[a-zA-Z0-9_-]+)* # but in sub key access only numbers are fine (foo.55)
\\s*[:=]\\s* # Key|Value delimiter : or =
(?: # BEGIN Possible value options
(?P<QuotedString> # Quoted string
"(?:\\\\"|[^"])*"
|\'(?:\\\\\'|[^\'])*\'
)
|(?P<VariableIdentifier>
(?:(?=[^,{}\.]*[a-zA-Z])[a-zA-Z0-9_-]*) # variable identifiers must contain letters (otherwise they are hardcoded numbers)
(?:\\.[a-zA-Z0-9_-]+)* # but in sub key access only numbers are fine (foo.55)
)
|(?P<Number>[0-9]+(?:\\.[0-9]+)?) # A hardcoded Number (also possibly with decimals)
|\\{\\s*(?P<Subarray>(?:(?P>ArrayPart)\\s*,?\\s*)+)\\s*\\} # Another sub-array
) # END possible value options
)
|(?:
[\.]{3}\\s* # Array unpacking (spread operator)
(?P<SpreadVariableIdentifier>
(?:(?=[^,{}\.]*[a-zA-Z])[a-zA-Z0-9_-]*)
(?:\\.[a-zA-Z0-9_-]+)*
)
|(?P<Number>[0-9]+(?:\\.[0-9]+)?) # A hardcoded Number (also possibly with decimals)
|\\{\\s*(?P<Subarray>(?:(?P>ArrayPart)\\s*,?\\s*)+)\\s*\\} # Another sub-array
) # END possible value options
)\\s*(?=\\z|,|\\}) # An array part sub-match ends with either a comma, a closing curly bracket or end of string
)
)\\s*(?=\\z|,|\\}) # An array part sub-match ends with either a comma, a closing curly bracket or end of string
/x';
}
29 changes: 23 additions & 6 deletions src/Core/Parser/SyntaxTree/ArrayNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
*/
class ArrayNode extends AbstractNode
{
public const SPREAD_PREFIX = '__spread';

/**
* Constructor.
*
Expand All @@ -37,7 +39,15 @@ public function evaluate(RenderingContextInterface $renderingContext)
{
$arrayToBuild = [];
foreach ($this->internalArray as $key => $value) {
$arrayToBuild[$key] = $value instanceof NodeInterface ? $value->evaluate($renderingContext) : $value;
if ($value instanceof NodeInterface) {
if (str_starts_with($key, self::SPREAD_PREFIX)) {
$arrayToBuild = [...$arrayToBuild, ...$value->evaluate($renderingContext)];
} else {
$arrayToBuild[$key] = $value->evaluate($renderingContext);
}
} else {
$arrayToBuild[$key] = $value;
}
}
return $arrayToBuild;
}
Expand All @@ -53,11 +63,18 @@ public function convert(TemplateCompiler $templateCompiler): array
if (!empty($converted['initialization'])) {
$accumulatedInitializationPhpCode .= $converted['initialization'];
}
$initializationPhpCode .= sprintf(
'\'%s\' => %s,' . chr(10),
$key,
$converted['execution']
);
if (str_starts_with($key, self::SPREAD_PREFIX)) {
$initializationPhpCode .= sprintf(
'...%s,' . chr(10),
$converted['execution']
);
} else {
$initializationPhpCode .= sprintf(
'\'%s\' => %s,' . chr(10),
$key,
$converted['execution']
);
}
} elseif (is_numeric($value)) {
// handle int, float, numeric strings
$initializationPhpCode .= sprintf(
Expand Down
8 changes: 7 additions & 1 deletion src/Core/Parser/TemplateParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -761,9 +761,15 @@ protected function recursiveArrayHandler(ParsingState $state, $arrayText, ViewHe
}
$matches = [];
$arrayToBuild = [];
$spreadVariableCounter = 0;
if (preg_match_all(Patterns::$SPLIT_PATTERN_SHORTHANDSYNTAX_ARRAY_PARTS, $arrayText, $matches, PREG_SET_ORDER)) {
foreach ($matches as $singleMatch) {
$arrayKey = $this->unquoteString($singleMatch['Key']);
if (array_key_exists('SpreadVariableIdentifier', $singleMatch)) {
$arrayKey = ArrayNode::SPREAD_PREFIX . $spreadVariableCounter++;
$singleMatch['VariableIdentifier'] = $singleMatch['SpreadVariableIdentifier'];
} else {
$arrayKey = $this->unquoteString($singleMatch['Key']);
}
$assignInto = &$arrayToBuild;
$isBoolean = false;
$argumentDefinition = null;
Expand Down
119 changes: 119 additions & 0 deletions tests/Functional/Cases/Parsing/ArraySyntaxTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

declare(strict_types=1);

/*
* This file belongs to the package "TYPO3 Fluid".
* See LICENSE.txt that was shipped with this package.
*/

namespace TYPO3Fluid\Fluid\Tests\Functional\Cases\Parsing;

use TYPO3Fluid\Fluid\Tests\Functional\AbstractFunctionalTestCase;
use TYPO3Fluid\Fluid\View\TemplateView;

final class ArraySyntaxTest extends AbstractFunctionalTestCase
{
public static function arraySyntaxDataProvider(): array
{
return [
// Edge case: Fluid treats this expression as an object accessor instead of an array
'single array spread without whitespace' => [
'<f:variable name="result" value="{...input1}" />',
[
'input1' => ['abc' => 1, 'def' => 2],
],
null,
],
// Edge case: Fluid treats this expression as an object accessor instead of an array
'single array spread with whitespace after' => [
'<f:variable name="result" value="{...input1 }" />',
[
'input1' => ['abc' => 1, 'def' => 2],
],
null,
],
'single array spread with whitespace before' => [
'<f:variable name="result" value="{ ...input1}" />',
[
'input1' => ['abc' => 1, 'def' => 2],
],
['abc' => 1, 'def' => 2],
],
'single array spread' => [
'<f:variable name="result" value="{ ...input1 }" />',
[
'input1' => ['abc' => 1, 'def' => 2],
],
['abc' => 1, 'def' => 2],
],
'multiple array spreads' => [
'<f:variable name="result" value="{ ...input1, ...input2 }" />',
[
'input1' => ['abc' => 1, 'def' => 2],
'input2' => ['ghi' => 3],
],
['abc' => 1, 'def' => 2, 'ghi' => 3],
],
'multiple array spreads mixed with other items' => [
'<f:variable name="result" value="{ first: 1, ...input1, middle: \'middle value\', ...input2, last: { sub: 1 } }" />',
[
'input1' => ['abc' => 1, 'def' => 2],
'input2' => ['ghi' => 3],
],
['first' => 1, 'abc' => 1, 'def' => 2, 'middle' => 'middle value', 'ghi' => 3, 'last' => ['sub' => 1]],
],
'overwrite static value' => [
'<f:variable name="result" value="{ abc: 10, ...input1 }" />',
[
'input1' => ['abc' => 1, 'def' => 2],
],
['abc' => 1, 'def' => 2],
],
'overwrite spreaded value' => [
'<f:variable name="result" value="{ ...input1, abc: 10 }" />',
[
'input1' => ['abc' => 1, 'def' => 2],
],
['abc' => 10, 'def' => 2],
],
'overwrite spreaded value with spreaded value' => [
'<f:variable name="result" value="{ ...input1, ...input2 }" />',
[
'input1' => ['abc' => 1, 'def' => 2],
'input2' => ['abc' => 10],
],
['abc' => 10, 'def' => 2],
],
'whitespace variants' => [
'<f:variable name="result" value="{... input1 , ... input2}" />',
[
'input1' => ['abc' => 1, 'def' => 2],
'input2' => ['ghi' => 3],
],
['abc' => 1, 'def' => 2, 'ghi' => 3],
]
];
}

/**
* @test
* @dataProvider arraySyntaxDataProvider
*/
public function arraySyntax(string $source, array $variables, $expected): void
{
$view = new TemplateView();
$view->getRenderingContext()->setCache(self::$cache);
$view->getRenderingContext()->getTemplatePaths()->setTemplateSource($source);
$view->assignMultiple($variables);
$view->render();
self::assertSame($view->getRenderingContext()->getVariableProvider()->get('result'), $expected);

$view = new TemplateView();
$view->getRenderingContext()->setCache(self::$cache);
$view->getRenderingContext()->getTemplatePaths()->setTemplateSource($source);
$view->assignMultiple($variables);
$view->render();
self::assertSame($view->getRenderingContext()->getVariableProvider()->get('result'), $expected);
}
}
31 changes: 31 additions & 0 deletions tests/Unit/Core/Parser/TemplateParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,37 @@ public static function dataProviderRecursiveArrayHandler(): \Generator
]
];

yield 'Single array spread' => [
'string' => '...some.identifier',
'expected' => [
'__spread0' => new ObjectAccessorNode('some.identifier')
]
];

yield 'Multiple arrays spread' => [
'string' => '...some.identifier, ...other.identifier',
'expected' => [
'__spread0' => new ObjectAccessorNode('some.identifier'),
'__spread1' => new ObjectAccessorNode('other.identifier')
]
];

yield 'Mixed types and arrays spread' => [
'string' => 'number: 123, string: \'some.string\', identifier: some.identifier, ...some.identifier, array: {number: 123, string: \'some.string\', identifier: some.identifier}, ...other.identifier',
'expected' => [
'number' => 123,
'string' => new TextNode('some.string'),
'identifier' => new ObjectAccessorNode('some.identifier'),
'__spread0' => new ObjectAccessorNode('some.identifier'),
'array' => new ArrayNode([
'number' => 123,
'string' => new TextNode('some.string'),
'identifier' => new ObjectAccessorNode('some.identifier')
]),
'__spread1' => new ObjectAccessorNode('other.identifier')
]
];

$rootNode = new RootNode();
$rootNode->addChildNode(new ObjectAccessorNode('some.{index}'));
yield 'variable identifier' => [
Expand Down

0 comments on commit 7f826b7

Please sign in to comment.