Passed
Pull Request — master (#737)
by butschster
06:36
created

NamedArgumentsInstantiator::analyzeKeys()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 20
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 9
c 1
b 0
f 0
nc 4
nop 1
dl 0
loc 20
ccs 0
cts 10
cp 0
crap 20
rs 9.9666
1
<?php
2
3
/**
4
 * This file is part of Spiral Framework package.
5
 *
6
 * For the full copyright and license information, please view the LICENSE
7
 * file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace Spiral\Attributes\Internal\Instantiator;
13
14
use Spiral\Attributes\Internal\Exception;
15
16
/**
17
 * @internal NamedArgumentsInstantiator is an internal library class, please do not use it in your code.
18
 * @psalm-internal Spiral\Attributes
19
 */
20
final class NamedArgumentsInstantiator extends Instantiator
21
{
22
    /**
23
     * @var string
24
     */
25
    private const ERROR_ARGUMENT_NOT_PASSED = '%s::__construct(): Argument #%d ($%s) not passed';
26
27
    /**
28
     * @var string
29
     */
30
    private const ERROR_OVERWRITE_ARGUMENT = 'Named parameter $%s overwrites previous argument';
31
32
    /**
33
     * @var string
34
     */
35
    private const ERROR_NAMED_ARG_TO_VARIADIC = 'Cannot pass named argument $%s to variadic parameter ...$%s in PHP < 8';
36
37
    /**
38
     * @var string
39
     */
40
    private const ERROR_UNKNOWN_ARGUMENT = 'Unknown named parameter $%s';
41
42
    /**
43
     * @var string
44
     */
45
    private const ERROR_POSITIONAL_AFTER_NAMED = 'Cannot use positional argument after named argument';
46
47
    /**
48
     * {@inheritDoc}
49
     */
50 26
    public function instantiate(\ReflectionClass $attr, array $arguments, \Reflector $context = null): object
51
    {
52 26
        if ($this->isNamedArgumentsSupported()) {
53
            try {
54 26
                return $attr->newInstanceArgs($arguments);
55 6
            } catch (\Throwable $e) {
56 6
                throw Exception::withLocation($e, $attr->getFileName(), $attr->getStartLine());
57
            }
58
        }
59
60
        $constructor = $this->getConstructor($attr);
61
62
        if ($constructor === null) {
63
            return $attr->newInstanceWithoutConstructor();
64
        }
65
66
        return $attr->newInstanceArgs(
67
            $this->resolveParameters($attr, $constructor, $arguments)
68
        );
69
    }
70
71 26
    private function isNamedArgumentsSupported(): bool
72
    {
73 26
        return \version_compare(\PHP_VERSION, '8.0') >= 0;
74
    }
75
76
    /**
77
     * @throws \Throwable
78
     */
79
    private function resolveParameters(\ReflectionClass $ctx, \ReflectionMethod $constructor, array $arguments): array
80
    {
81
        try {
82
            return $this->doResolveParameters($ctx, $constructor, $arguments);
83
        } catch (\Throwable $e) {
84
            throw Exception::withLocation($e, $constructor->getFileName(), $constructor->getStartLine());
85
        }
86
    }
87
88
    /**
89
     * @return array
90
     * @throws \Throwable
91
     */
92
    private function doResolveParameters(\ReflectionClass $ctx, \ReflectionMethod $constructor, array $arguments): array
93
    {
94
        $namedArgsBegin = $this->analyzeKeys($arguments);
95
96
        if ($namedArgsBegin === null) {
97
            // Only numeric / positional keys exist.
98
            return $arguments;
99
        }
100
101
        if ($namedArgsBegin === 0) {
102
            // Only named keys exist.
103
            $passed = [];
104
            $named = $arguments;
105
        } else {
106
            // Numeric/positional keys followed by named keys.
107
            // No need to preserve numeric keys.
108
            $passed = array_slice($arguments, 0, $namedArgsBegin);
109
            $named = array_slice($arguments, $namedArgsBegin);
110
        }
111
112
        return $this->appendNamedArgs(
113
            $ctx,
114
            $passed,
115
            $named,
116
            $namedArgsBegin,
117
            $constructor->getParameters()
118
        );
119
    }
120
121
    /**
122
     * Analyzes keys of an arguments array.
123
     *
124
     * @param array $arguments
125
     *   By reference. Numeric keys will be reordered.
126
     *   Before (success): Mixed numeric keys, then only string keys.
127
     *   Before (fail): Some string keys are followed by numeric keys.
128
     *   After (success): Seq. numeric keys starting from 0, then string keys.
129
     *   After (failure): Seq. numeric keys starting from 0, mixed with string
130
     *     keys.
131
     *
132
     * @return int|null
133
     *   Position of the first string key, or NULL if all keys are numeric.
134
     */
135
    private function analyzeKeys(array &$arguments): ?int
136
    {
137
        // Normalize all numeric keys, but keep string keys.
138
        $arguments = array_merge($arguments);
139
140
        $i = 0;
141
        foreach ($arguments as $k => $_) {
142
            if ($k !== $i) {
143
                // This must be a string key.
144
                // Any further numeric keys are illegal.
145
                if (\array_key_exists($i, $arguments)) {
146
                    throw new \BadMethodCallException(self::ERROR_POSITIONAL_AFTER_NAMED);
147
                }
148
                return $i;
149
            }
150
            ++$i;
151
        }
152
153
        // All keys must be numeric.
154
        return null;
155
    }
156
157
    /**
158
     * @param \ReflectionClass $ctx
159
     * @param array $passed
160
     *   Positional arguments.
161
     *   Format: $[] = $value.
162
     * @param array $named
163
     *   Named arguments.
164
     *   Format: $[$name] = $value.
165
     * @param int $namedArgsBegin
166
     *   Position of first named argument.
167
     *   This is identical to count($passed).
168
     * @param \ReflectionParameter[] $parameters
169
     *   Full list of parameters.
170
     *
171
     * @return array
172
     *   Sequential list of all parameter values.
173
     *   Format: $[] = $value.
174
     *
175
     * @throws \Throwable
176
     *   Arguments provided are incompatible with the parameters.
177
     */
178
    private function appendNamedArgs(\ReflectionClass $ctx, array $passed, array $named, int $namedArgsBegin, array $parameters): array
179
    {
180
        // Analyze parameters.
181
        $n = count($parameters);
182
        if ($n > 0 && end($parameters)->isVariadic()) {
183
            $variadicParameter = end($parameters);
184
            // Don't include the variadic parameter in the mapping process.
185
            --$n;
186
        } else {
187
            $variadicParameter = null;
188
        }
189
190
        // Process parameters that are not already filled with positional args.
191
        // This loop will do nothing if $namedArgsBegin >= $n. That's ok.
192
        for ($i = $namedArgsBegin; $i < $n; ++$i) {
193
            $parameter = $parameters[$i];
194
            $k = $parameter->getName();
195
            if (array_key_exists($k, $named)) {
196
                $passed[] = $named[$k];
197
                unset($named[$k]);
198
            } elseif ($parameter->isDefaultValueAvailable()) {
199
                $passed[] = $parameter->getDefaultValue();
200
            } else {
201
                $message = \vsprintf(self::ERROR_ARGUMENT_NOT_PASSED, [
202
                    $ctx->getName(),
203
                    $parameter->getPosition() + 1,
204
                    $parameter->getName(),
205
                ]);
206
207
                throw new \ArgumentCountError($message);
208
            }
209
        }
210
211
        if ($named === []) {
212
            // No unknown argument names exist.
213
            return $passed;
214
        }
215
216
        // Analyze the first bad argument name, ignore the rest.
217
        reset($named);
218
        $badArgName = key($named);
219
220
        // Check collision with positional arguments.
221
        foreach ($parameters as $i => $parameter) {
222
            if ($i >= $namedArgsBegin) {
223
                break;
224
            }
225
            if ($parameter->getName() === $badArgName) {
226
                // The named argument overwrites a positional argument.
227
                $message = \sprintf(self::ERROR_OVERWRITE_ARGUMENT, $badArgName);
228
                throw new \BadMethodCallException($message);
229
            }
230
        }
231
232
        // Special handling if a variadic parameter is present.
233
        if ($variadicParameter !== null) {
234
            // The last parameter is variadic.
235
            // Since PHP 8+, variadic parameters can consume named arguments.
236
            // However, this code only runs if PHP < 8.
237
            $message = \vsprintf(self::ERROR_NAMED_ARG_TO_VARIADIC, [
238
                $badArgName,
239
                $variadicParameter->getName(),
240
            ]);
241
            throw new \BadMethodCallException($message);
242
        }
243
244
        // No variadic parameter exists.
245
        // Unknown named arguments are illegal in this case, even in PHP 8.
246
        $message = \sprintf(self::ERROR_UNKNOWN_ARGUMENT, $badArgName);
247
        throw new \BadMethodCallException($message);
248
    }
249
}
250