1<?php 2 3/* 4 * This file is part of Twig. 5 * 6 * (c) Fabien Potencier 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Twig\Util; 13 14use Twig\Error\SyntaxError; 15use Twig\Node\Expression\ArrayExpression; 16use Twig\Node\Expression\CallExpression; 17use Twig\Node\Expression\ConstantExpression; 18use Twig\Node\Expression\VariadicExpression; 19use Twig\Node\Node; 20use Twig\TwigCallableInterface; 21 22/** 23 * @author Fabien Potencier <fabien@symfony.com> 24 * 25 * @internal 26 */ 27final class CallableArgumentsExtractor 28{ 29 private ReflectionCallable $rc; 30 31 public function __construct( 32 private Node $node, 33 private TwigCallableInterface $twigCallable, 34 ) { 35 $this->rc = new ReflectionCallable($twigCallable); 36 } 37 38 /** 39 * @return array<Node> 40 */ 41 public function extractArguments(Node $arguments): array 42 { 43 $extractedArguments = []; 44 $extractedArgumentNameMap = []; 45 $named = false; 46 foreach ($arguments as $name => $node) { 47 if (!\is_int($name)) { 48 $named = true; 49 } elseif ($named) { 50 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()); 51 } 52 53 $extractedArguments[$normalizedName = $this->normalizeName($name)] = $node; 54 $extractedArgumentNameMap[$normalizedName] = $name; 55 } 56 57 if (!$named && !$this->twigCallable->isVariadic()) { 58 $min = $this->twigCallable->getMinimalNumberOfRequiredArguments(); 59 if (\count($extractedArguments) < $this->rc->getReflector()->getNumberOfRequiredParameters() - $min) { 60 $argName = $this->toSnakeCase($this->rc->getReflector()->getParameters()[$min + \count($extractedArguments)]->getName()); 61 62 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()); 63 } 64 65 return $extractedArguments; 66 } 67 68 if (!$callable = $this->twigCallable->getCallable()) { 69 if ($named) { 70 throw new SyntaxError(\sprintf('Named arguments are not supported for %s "%s".', $this->twigCallable->getType(), $this->twigCallable->getName())); 71 } 72 73 throw new SyntaxError(\sprintf('Arbitrary positional arguments are not supported for %s "%s".', $this->twigCallable->getType(), $this->twigCallable->getName())); 74 } 75 76 [$callableParameters, $isPhpVariadic] = $this->getCallableParameters(); 77 $arguments = []; 78 $callableParameterNames = []; 79 $missingArguments = []; 80 $optionalArguments = []; 81 $pos = 0; 82 foreach ($callableParameters as $callableParameter) { 83 $callableParameterName = $callableParameter->name; 84 if (\PHP_VERSION_ID >= 80000 && 'range' === $callable) { 85 if ('start' === $callableParameterName) { 86 $callableParameterName = 'low'; 87 } elseif ('end' === $callableParameterName) { 88 $callableParameterName = 'high'; 89 } 90 } 91 92 $callableParameterNames[] = $callableParameterName; 93 $normalizedCallableParameterName = $this->normalizeName($callableParameterName); 94 95 if (\array_key_exists($normalizedCallableParameterName, $extractedArguments)) { 96 if (\array_key_exists($pos, $extractedArguments)) { 97 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()); 98 } 99 100 if (\count($missingArguments)) { 101 throw new SyntaxError(\sprintf( 102 '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".', 103 $callableParameterName, $this->twigCallable->getType(), $this->twigCallable->getName(), implode(', ', array_map([$this, 'toSnakeCase'], $callableParameterNames)), \count($missingArguments) > 1 ? 's' : '', implode('", "', $missingArguments) 104 ), $this->node->getTemplateLine(), $this->node->getSourceContext()); 105 } 106 107 $arguments = array_merge($arguments, $optionalArguments); 108 $arguments[] = $extractedArguments[$normalizedCallableParameterName]; 109 unset($extractedArguments[$normalizedCallableParameterName]); 110 $optionalArguments = []; 111 } elseif (\array_key_exists($pos, $extractedArguments)) { 112 $arguments = array_merge($arguments, $optionalArguments); 113 $arguments[] = $extractedArguments[$pos]; 114 unset($extractedArguments[$pos]); 115 $optionalArguments = []; 116 ++$pos; 117 } elseif ($callableParameter->isDefaultValueAvailable()) { 118 $optionalArguments[] = new ConstantExpression($callableParameter->getDefaultValue(), $this->node->getTemplateLine()); 119 } elseif ($callableParameter->isOptional()) { 120 if (!$extractedArguments) { 121 break; 122 } 123 124 $missingArguments[] = $callableParameterName; 125 } else { 126 throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $this->toSnakeCase($callableParameterName), $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext()); 127 } 128 } 129 130 if ($this->twigCallable->isVariadic()) { 131 $arbitraryArguments = $isPhpVariadic ? new VariadicExpression([], $this->node->getTemplateLine()) : new ArrayExpression([], $this->node->getTemplateLine()); 132 foreach ($extractedArguments as $key => $value) { 133 if (\is_int($key)) { 134 $arbitraryArguments->addElement($value); 135 } else { 136 $originalKey = $extractedArgumentNameMap[$key]; 137 if ($originalKey !== $this->toSnakeCase($originalKey)) { 138 trigger_deprecation('twig/twig', '3.15', \sprintf('Using "snake_case" for variadic arguments is required for a smooth upgrade with Twig 4.0; rename "%s" to "%s" in "%s" at line %d.', $originalKey, $this->toSnakeCase($originalKey), $this->node->getSourceContext()->getName(), $this->node->getTemplateLine())); 139 } 140 $arbitraryArguments->addElement($value, new ConstantExpression($this->toSnakeCase($originalKey), $this->node->getTemplateLine())); 141 // I Twig 4.0, don't convert the key: 142 // $arbitraryArguments->addElement($value, new ConstantExpression($originalKey, $this->node->getTemplateLine())); 143 } 144 unset($extractedArguments[$key]); 145 } 146 147 if ($arbitraryArguments->count()) { 148 $arguments = array_merge($arguments, $optionalArguments); 149 $arguments[] = $arbitraryArguments; 150 } 151 } 152 153 if ($extractedArguments) { 154 $unknownArgument = null; 155 foreach ($extractedArguments as $extractedArgument) { 156 if ($extractedArgument instanceof Node) { 157 $unknownArgument = $extractedArgument; 158 break; 159 } 160 } 161 162 throw new SyntaxError( 163 \sprintf( 164 'Unknown argument%s "%s" for %s "%s(%s)".', 165 \count($extractedArguments) > 1 ? 's' : '', implode('", "', array_keys($extractedArguments)), $this->twigCallable->getType(), $this->twigCallable->getName(), implode(', ', array_map([$this, 'toSnakeCase'], $callableParameterNames)) 166 ), 167 $unknownArgument ? $unknownArgument->getTemplateLine() : $this->node->getTemplateLine(), 168 $unknownArgument ? $unknownArgument->getSourceContext() : $this->node->getSourceContext() 169 ); 170 } 171 172 return $arguments; 173 } 174 175 private function normalizeName(string $name): string 176 { 177 return strtolower(str_replace('_', '', $name)); 178 } 179 180 private function toSnakeCase(string $name): string 181 { 182 return strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z0-9])([A-Z])/'], '\1_\2', $name)); 183 } 184 185 private function getCallableParameters(): array 186 { 187 $parameters = $this->rc->getReflector()->getParameters(); 188 if ($this->node->hasNode('node')) { 189 array_shift($parameters); 190 } 191 if ($this->twigCallable->needsCharset()) { 192 array_shift($parameters); 193 } 194 if ($this->twigCallable->needsEnvironment()) { 195 array_shift($parameters); 196 } 197 if ($this->twigCallable->needsContext()) { 198 array_shift($parameters); 199 } 200 if (CallExpression::needsIsSandboxed($this->twigCallable)) { 201 array_shift($parameters); 202 } 203 foreach ($this->twigCallable->getArguments() as $argument) { 204 array_shift($parameters); 205 } 206 207 $isPhpVariadic = false; 208 if ($this->twigCallable->isVariadic()) { 209 $argument = end($parameters); 210 $isArray = $argument && $argument->hasType() && $argument->getType() instanceof \ReflectionNamedType && 'array' === $argument->getType()->getName(); 211 if ($isArray && $argument->isDefaultValueAvailable() && [] === $argument->getDefaultValue()) { 212 array_pop($parameters); 213 } elseif ($argument && $argument->isVariadic()) { 214 array_pop($parameters); 215 $isPhpVariadic = true; 216 } else { 217 throw new SyntaxError(\sprintf('The last parameter of "%s" for %s "%s" must be an array with default value, eg. "array $arg = []".', $this->rc->getName(), $this->twigCallable->getType(), $this->twigCallable->getName())); 218 } 219 } 220 221 return [$parameters, $isPhpVariadic]; 222 } 223} 224