Passed
Push — main ( b92cd4...d72029 )
by Thomas
03:18
created

Registry::normalizeParameterName()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3

Importance

Changes 0
Metric Value
eloc 4
dl 0
loc 9
ccs 5
cts 5
cp 1
rs 10
c 0
b 0
f 0
cc 3
nc 3
nop 1
crap 3
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Conia\Chuck;
6
7
use Closure;
8
use Conia\Chuck\Exception\ContainerException;
9
use Conia\Chuck\Exception\NotFoundException;
10
use Psr\Container\ContainerInterface;
11
use ReflectionClass;
12
use ReflectionFunction;
13
use ReflectionFunctionAbstract;
14
use ReflectionMethod;
15
use ReflectionNamedType;
16
use ReflectionParameter;
17
use Throwable;
18
19
/**
20
 * @psalm-type EntryArray = array<never, never>|array<string, RegistryEntry>
21
 */
22
class Registry implements ContainerInterface
23
{
24
    /** @var EntryArray */
0 ignored issues
show
Bug introduced by
The type Conia\Chuck\EntryArray was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
25
    protected array $entries = [];
26
27
    /** @var array<never, never>|array<non-empty-string, self> */
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<never, never>|array<non-empty-string, self> at position 9 could not be parsed: Unknown type name 'non-empty-string' at position 9 in array<never, never>|array<non-empty-string, self>.
Loading history...
28
    protected array $tags = [];
29
30 125
    public function __construct(
31
        protected readonly ?ContainerInterface $container = null,
32
        protected readonly bool $autowire = true,
33
        protected readonly string $tag = '',
34
    ) {
35 125
        if ($container) {
36 2
            $this->add(ContainerInterface::class, $container);
37 2
            $this->add($container::class, $container);
38
        } else {
39 125
            $this->add(ContainerInterface::class, $this);
40
        }
41
42 125
        $this->add(Registry::class, $this);
43
    }
44
45 2
    public function has(string $id): bool
46
    {
47 2
        return isset($this->entries[$id]) || $this->container?->has($id);
48
    }
49
50 17
    public function entry(string $id, string $paramName = ''): RegistryEntry
51
    {
52 17
        $paramName = $this->normalizeParameterName($paramName);
53
54 17
        return $this->entries[$id . $paramName];
55
    }
56
57 81
    public function get(string $id): mixed
58
    {
59 81
        $entry = $this->entries[$id] ?? null;
60
61 81
        if ($entry) {
62 73
            return $this->resolveEntry($entry);
63
        }
64
65 13
        if ($this->container?->has($id)) {
66 2
            return $this->container->get($id);
0 ignored issues
show
Bug introduced by
The method get() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

66
            return $this->container->/** @scrutinizer ignore-call */ get($id);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
67
        }
68
69
        // Autowiring: $id does not exists as an entry in the registry
70 11
        if ($this->autowire && class_exists($id)) {
71 8
            return $this->autowire($id);
72
        }
73
74 4
        $message = empty($this->tag) ?
75 3
            'Unresolvable id: ' . $id :
76 1
            'Unresolvable tagged id: ' . $this->tag . '::' . $id;
77
78 4
        throw new NotFoundException($message);
79
    }
80
81
    /**
82
     * @psalm-param non-empty-string $id
83
     */
84 125
    public function add(
85
        string $id,
86
        mixed $value = null,
87
        string $paramName = '',
88
    ): RegistryEntry {
89 125
        $paramName = $this->normalizeParameterName($paramName);
90 125
        $entry = new RegistryEntry($id, $value ?? $id);
91 125
        $this->entries[$id . $paramName] = $entry;
92
93 125
        return $entry;
94
    }
95
96
    /** @psalm-param non-empty-string $tag */
97 89
    public function tag(string $tag): Registry
98
    {
99 89
        if (!isset($this->tags[$tag])) {
100 89
            $this->tags[$tag] = new self(tag: $tag);
101
        }
102
103 89
        return $this->tags[$tag];
104
    }
105
106 4
    public function new(string $id, mixed ...$args): object
107
    {
108 4
        $entry = $this->entries[$id] ?? null;
109
110 4
        if ($entry) {
111
            /** @var mixed */
112 2
            $value = $entry->definition();
113
114 2
            if (is_string($value)) {
115 2
                if (interface_exists($value)) {
116 1
                    return $this->new($value, ...$args);
117
                }
118
119 2
                if (class_exists($value)) {
120
                    /** @psalm-suppress MixedMethodCall */
121 2
                    return new $value(...$args);
122
                }
123
            }
124
        }
125
126 2
        if ($this->autowire && class_exists($id)) {
127
            /** @psalm-suppress MixedMethodCall */
128 1
            return new $id(...$args);
129
        }
130
131 1
        throw new NotFoundException('Cannot instantiate ' . $id);
132
    }
133
134 21
    public function getWithParamName(string $id, string $paramName): mixed
135
    {
136 21
        $paramName = $this->normalizeParameterName($paramName);
137
138
        // See if there's a entry with a bound parameter name:
139
        // e. g. '\Namespace\MyClass$myParameter'
140
        // If $paramName is emtpy an existing unbound entry should
141
        // be found on first try.
142 21
        return isset($this->entries[$id . $paramName]) ?
143 2
            $this->resolveEntry($this->entries[$id . $paramName]) :
144 21
            $this->get($id);
145
    }
146
147 24
    public function resolveParam(ReflectionParameter $param): mixed
148
    {
149 24
        $type = $param->getType();
150
151 24
        if ($type instanceof ReflectionNamedType) {
152
            try {
153 20
                return $this->getWithParamName($type->getName(), '$' . ltrim($param->getName(), '?'));
154 3
            } catch (NotFoundException $e) {
155 2
                if ($param->isDefaultValueAvailable()) {
156 1
                    return $param->getDefaultValue();
157
                }
158
159 1
                throw $e;
160
            }
161
        } else {
162 4
            if ($type) {
163 2
                throw new ContainerException(
164 2
                    "Autowiring does not support union or intersection types. Source: \n" .
165 2
                        $this->getParamInfo($param)
166 2
                );
167
            }
168
169 2
            throw new ContainerException(
170 2
                "Autowired entities need to have typed constructor parameters. Source: \n" .
171 2
                    $this->getParamInfo($param)
172 2
            );
173
        }
174
    }
175
176 6
    public function getParamInfo(ReflectionParameter $param): string
177
    {
178 6
        $type = $param->getType();
179 6
        $rf = $param->getDeclaringFunction();
180 6
        $rc = null;
181
182 6
        if ($rf instanceof ReflectionMethod) {
183 6
            $rc = $rf->getDeclaringClass();
184
        }
185
186 6
        return ($rc ? $rc->getName() . '::' : '') .
187 6
            ($rf->getName() . '(..., ') .
188 6
            ($type ? (string)$type . ' ' : '') .
189 6
            '$' . $param->getName() . ', ...)';
190
    }
191
192 73
    protected function resolveEntry(RegistryEntry $entry): mixed
193
    {
194 73
        if ($entry->shouldReturnAsIs()) {
195 3
            return $entry->definition();
196
        }
197
198
        /** @var mixed - the current value, instantiated or definition */
199 71
        $value = $entry->get();
200
201 71
        if (is_string($value)) {
202 63
            if (class_exists($value)) {
203 62
                $args = $entry->getArgs();
204
205 62
                if (isset($args)) {
206
                    // Don't autowire if $args are given
207 4
                    if ($args instanceof Closure) {
208 1
                        return $this->reifyAndReturn($entry, $this->fromArgsClosure($value, $args));
209
                    }
210
211 3
                    return $this->reifyAndReturn($entry, $this->fromArgsArray($value, $args));
212
                }
213
214 58
                return $this->reifyAndReturn($entry, $this->autowire($value));
215
            }
216
217 2
            if (isset($this->entries[$value])) {
218 1
                return $this->get($value);
219
            }
220
        }
221
222 56
        if ($value instanceof Closure) {
223
            // Get the instance from the registered closure
224 9
            $rf = new ReflectionFunction($value);
225 9
            $args = $entry->getArgs();
226
227 9
            if (is_null($args)) {
228 7
                $args = $this->resolveArgs($rf);
229 2
            } elseif ($args instanceof Closure) {
230
                /** @var array<string, mixed> */
231 1
                $args = $args();
232
            }
233
234
            /** @var mixed */
235 9
            $result = $value(...$args);
236
237 9
            return $this->reifyAndReturn($entry, $result);
238
        }
239
240 53
        if (is_object($value)) {
241 52
            return $value;
242
        }
243
244 1
        throw new NotFoundException('Unresolvable id: ' . print_r($value, true));
0 ignored issues
show
Bug introduced by
Are you sure print_r($value, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

244
        throw new NotFoundException('Unresolvable id: ' . /** @scrutinizer ignore-type */ print_r($value, true));
Loading history...
245
    }
246
247 65
    protected function reifyAndReturn(RegistryEntry $entry, mixed $value): mixed
248
    {
249 65
        if ($entry->shouldReify()) {
250 64
            $entry->set($value);
251
        }
252
253 65
        return $value;
254
    }
255
256
    /** @psalm-param class-string $class */
257 64
    protected function autowire(string $class): object
258
    {
259 64
        $rc = new ReflectionClass($class);
260 64
        $constructor = $rc->getConstructor();
261 64
        $args = $this->resolveArgs($constructor);
262
263
        try {
264 61
            return $rc->newInstance(...$args);
265 2
        } catch (Throwable $e) {
266 2
            throw new ContainerException(
267 2
                'Autowiring unresolvable: ' . $class . ' Details: ' . $e->getMessage()
268 2
            );
269
        }
270
    }
271
272 67
    protected function resolveArgs(?ReflectionFunctionAbstract $rf): array
273
    {
274 67
        $args = [];
275
276 67
        if ($rf) {
277 15
            foreach ($rf->getParameters() as $param) {
278
                /** @var list<mixed> */
279 9
                $args[] = $this->resolveParam($param);
280
            }
281
        }
282
283 64
        return $args;
284
    }
285
286
    /** @psalm-param class-string $class */
287 3
    protected function fromArgsArray(string $class, array $args): object
288
    {
289
        /** @psalm-suppress MixedMethodCall */
290 3
        return new $class(...$args);
291
    }
292
293
    /** @psalm-param class-string $class */
294 1
    protected function fromArgsClosure(string $class, Closure $callback): object
295
    {
296 1
        $rf = new ReflectionFunction($callback);
297 1
        $args = $this->resolveArgs($rf);
298
299
        /** @psalm-suppress MixedMethodCall */
300 1
        return new $class(...$callback(...$args));
301
    }
302
303 125
    protected function normalizeParameterName(string $paramName): string
304
    {
305 125
        if (empty($paramName)) {
306 125
            return $paramName;
307
        }
308
309 22
        $paramName = trim($paramName);
310
311 22
        return str_starts_with($paramName, '$') ? $paramName : '$' . $paramName;
312
    }
313
}
314