Skip to content

Commit

Permalink
Allow Twig callable arguments to use camel or snake names
Browse files Browse the repository at this point in the history
  • Loading branch information
fabpot committed Sep 14, 2024
1 parent 4c9526a commit 3149ab3
Show file tree
Hide file tree
Showing 2 changed files with 62 additions and 11 deletions.
47 changes: 36 additions & 11 deletions src/Util/CallableArgumentsExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ public function extractArguments(Node $arguments): array
foreach ($arguments as $name => $node) {
if (!\is_int($name)) {
$named = true;
$name = $this->normalizeName($name);
} elseif ($named) {
throw new SyntaxError(\sprintf('Positional arguments cannot be used after named arguments for %s "%s".', $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext());
}
Expand All @@ -55,7 +54,9 @@ public function extractArguments(Node $arguments): array
if (!$named && !$this->twigCallable->isVariadic()) {
$min = $this->twigCallable->getMinimalNumberOfRequiredArguments();
if (\count($extractedArguments) < $this->rc->getReflector()->getNumberOfRequiredParameters() - $min) {
throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $this->rc->getReflector()->getParameters()[$min + \count($extractedArguments)]->getName(), $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext());
$argName = $this->toSnakeCase($this->toCamelCase($this->rc->getReflector()->getParameters()[$min + \count($extractedArguments)]->getName()));

throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $argName, $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext());
}

return $extractedArguments;
Expand All @@ -76,7 +77,7 @@ public function extractArguments(Node $arguments): array
$optionalArguments = [];
$pos = 0;
foreach ($callableParameters as $callableParameter) {
$callableParameterName = $this->normalizeName($callableParameter->name);
$callableParameterName = $callableParameter->name;
if (\PHP_VERSION_ID >= 80000 && 'range' === $callable) {
if ('start' === $callableParameterName) {
$callableParameterName = 'low';
Expand All @@ -87,21 +88,26 @@ public function extractArguments(Node $arguments): array

$callableParameterNames[] = $callableParameterName;

if (\array_key_exists($callableParameterName, $extractedArguments)) {
if (
\array_key_exists($resolvedParameterName = $callableParameterName, $extractedArguments)
|| \array_key_exists($resolvedParameterName = $this->toCamelCase($callableParameterName), $extractedArguments)
// toSnakeCase() needs the camelCase string (as per Symfony implementation)
|| \array_key_exists($resolvedParameterName = $this->toSnakeCase($resolvedParameterName), $extractedArguments)
) {
if (\array_key_exists($pos, $extractedArguments)) {
throw new SyntaxError(\sprintf('Argument "%s" is defined twice for %s "%s".', $callableParameterName, $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext());
}

if (\count($missingArguments)) {
throw new SyntaxError(\sprintf(
'Argument "%s" could not be assigned for %s "%s(%s)" because it is mapped to an internal PHP function which cannot determine default value for optional argument%s "%s".',
$callableParameterName, $this->twigCallable->getType(), $this->twigCallable->getName(), implode(', ', $callableParameterNames), \count($missingArguments) > 1 ? 's' : '', implode('", "', $missingArguments)
$callableParameterName, $this->twigCallable->getType(), $this->twigCallable->getName(), implode(', ', $this->toSnakeCases($callableParameterNames)), \count($missingArguments) > 1 ? 's' : '', implode('", "', $missingArguments)
), $this->node->getTemplateLine(), $this->node->getSourceContext());
}

$arguments = array_merge($arguments, $optionalArguments);
$arguments[] = $extractedArguments[$callableParameterName];
unset($extractedArguments[$callableParameterName]);
$arguments[] = $extractedArguments[$resolvedParameterName];
unset($extractedArguments[$resolvedParameterName]);
$optionalArguments = [];
} elseif (\array_key_exists($pos, $extractedArguments)) {
$arguments = array_merge($arguments, $optionalArguments);
Expand All @@ -118,7 +124,7 @@ public function extractArguments(Node $arguments): array

$missingArguments[] = $callableParameterName;
} else {
throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $callableParameterName, $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext());
throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $this->toSnakeCase($this->toCamelCase($callableParameterName)), $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext());
}
}

Expand Down Expand Up @@ -151,7 +157,7 @@ public function extractArguments(Node $arguments): array
throw new SyntaxError(
\sprintf(
'Unknown argument%s "%s" for %s "%s(%s)".',
\count($extractedArguments) > 1 ? 's' : '', implode('", "', array_keys($extractedArguments)), $this->twigCallable->getType(), $this->twigCallable->getName(), implode(', ', $callableParameterNames)
\count($extractedArguments) > 1 ? 's' : '', implode('", "', array_keys($extractedArguments)), $this->twigCallable->getType(), $this->twigCallable->getName(), implode(', ', $this->toSnakeCases($callableParameterNames))
),
$unknownArgument ? $unknownArgument->getTemplateLine() : $this->node->getTemplateLine(),
$unknownArgument ? $unknownArgument->getSourceContext() : $this->node->getSourceContext()
Expand All @@ -161,9 +167,28 @@ public function extractArguments(Node $arguments): array
return $arguments;
}

private function normalizeName(string $name): string
// logic from Symfony\Component\String\AbstractUnicodeString::camel()
private function toCamelCase(string $str): string
{
return strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], ['\\1_\\2', '\\1_\\2'], $name));
return str_replace(' ', '', preg_replace_callback('/\b.(?!\p{Lu})/u', static function ($m) use (&$i) {
return 1 === ++$i ? ('İ' === $m[0] ? '' : mb_strtolower($m[0], 'UTF-8')) : mb_convert_case($m[0], \MB_CASE_TITLE, 'UTF-8');
}, preg_replace('/[^\pL0-9]++/u', ' ', $str)));
}

// logic from Symfony\Component\String\AbstractUnicodeString::snake()
private function toSnakeCase(string $str): string
{
return mb_strtolower(preg_replace(['/(\p{Lu}+)(\p{Lu}\p{Ll})/u', '/([\p{Ll}0-9])(\p{Lu})/u'], '\1_\2', $str), 'UTF-8');
}

private function toSnakeCases(array $strs): array
{
$snakes = [];
foreach ($strs as $str) {
$snakes[] = $this->toSnakeCase($str);
}

return $snakes;
}

private function getCallableParameters(): array
Expand Down
26 changes: 26 additions & 0 deletions tests/Util/CallableArgumentsExtractorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,28 @@ public function testGetArgumentsForStaticMethod()
$this->assertEquals(['arg1'], $this->getArguments('custom_static_function', __CLASS__.'::customStaticFunction', ['arg1' => 'arg1']));
}

/**
* @dataProvider getGetArgumentsForCamelSnakeCases
*/
public function testGetArgumentsForCamelSnakeCases(array $args)
{
$this->assertEquals(['Fabien', 'Paris'], $this->getArguments('custom_static_function', [$this, 'customFunctionSnakeCamel'], $args));
}

public static function getGetArgumentsForCamelSnakeCases()
{
yield [['someName' => 'Fabien', 'someCity' => 'Paris']];
yield [['some_name' => 'Fabien', 'some_city' => 'Paris']];
}

public function testGetArgumentsForCamelSnakeCasesError()
{
$this->expectException(SyntaxError::class);
$this->expectExceptionMessage('Value for argument "some_name" is required for function "custom_static_function".');

$this->getArguments('custom_static_function', [$this, 'customFunctionSnakeCamel'], ['someCity' => 'Paris']);
}

public function testResolveArgumentsWithMissingParameterForArbitraryArguments()
{
$this->expectException(SyntaxError::class);
Expand Down Expand Up @@ -119,6 +141,10 @@ public function customFunction($arg1, $arg2 = 'default', $arg3 = [])
{
}

public function customFunctionSnakeCamel($someName, $some_city)
{
}

public function customFunctionWithArbitraryArguments()
{
}
Expand Down

0 comments on commit 3149ab3

Please sign in to comment.