Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Array unpacking (spread operator) #810

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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