Passed
Push — main ( c5612c...d21d40 )
by Thomas
02:40
created

Registry::has()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 1
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Conia\Chuck;
6
7
use Closure;
8
use ReflectionClass;
9
use ReflectionFunction;
10
use ReflectionFunctionAbstract;
11
use ReflectionMethod;
12
use ReflectionNamedType;
13
use ReflectionParameter;
14
use Throwable;
15
use Conia\Chuck\Exception\ContainerException;
16
use Conia\Chuck\Exception\NotFoundException;
17
use Psr\Container\ContainerInterface;
18
19
class Registry implements ContainerInterface
20
{
21
    /** @var array<never, never>|array<string, Entry> */
22
    protected array $entries = [];
23
    protected readonly ?ContainerInterface $container;
24
25 112
    public function __construct(
26
        ?ContainerInterface $container = null,
27
        protected readonly bool $autowire = true
28
    ) {
29 112
        $this->container = $container;
0 ignored issues
show
Bug introduced by
The property container is declared read-only in Conia\Chuck\Registry.
Loading history...
30
31 112
        if ($container) {
32 4
            $this->addAnyway(ContainerInterface::class, $container);
33 4
            $this->addAnyway($container::class, $container);
34
        } else {
35 110
            $this->addAnyway(ContainerInterface::class, $this);
36
        }
37
38 112
        $this->addAnyway(Registry::class, $this);
39
    }
40
41 1
    public function has(string $id): bool
42
    {
43 1
        return isset($this->entries[$id]) || $this->container?->has($id);
44
    }
45
46 1
    public function entry(string $id, string $paramName = ''): mixed
47
    {
48 1
        $paramName = $this->normalizeParameterName($paramName);
49
50 1
        return $this->entries[$id . $paramName];
51
    }
52
53 68
    public function get(string $id): mixed
54
    {
55 68
        $entry = $this->entries[$id] ?? null;
56
57 68
        if ($entry) {
58 61
            return $this->resolveEntry($entry);
59
        }
60
61 12
        if ($this->container?->has($id)) {
62 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

62
            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...
63
        }
64
65
        // Autowiring: $id does not exists as an entry in the registry
66 10
        if ($this->autowire && class_exists($id)) {
67 8
            return $this->autowire($id);
68
        }
69
70 3
        throw new NotFoundException('Unresolvable id: ' . $id);
71
    }
72
73
    /**
74
     * @param non-empty-string $id
0 ignored issues
show
Documentation Bug introduced by
The doc comment non-empty-string at position 0 could not be parsed: Unknown type name 'non-empty-string' at position 0 in non-empty-string.
Loading history...
75
     */
76 83
    public function add(
77
        string $id,
78
        mixed $value,
79
        string $paramName = '',
80
    ): Entry {
81 83
        if ($this->container) {
82 2
            throw new ContainerException('Third party container implementation in use');
83
        }
84
85 81
        return $this->addAnyway($id, $value, $paramName);
86
    }
87
88
    /**
89
     * @param non-empty-string $id
0 ignored issues
show
Documentation Bug introduced by
The doc comment non-empty-string at position 0 could not be parsed: Unknown type name 'non-empty-string' at position 0 in non-empty-string.
Loading history...
90
     */
91 112
    public function addAnyway(
92
        string $id,
93
        mixed $value,
94
        string $paramName = '',
95
    ): Entry {
96 112
        $paramName = $this->normalizeParameterName($paramName);
97
98 112
        if ($id === $value) {
99 1
            throw new ContainerException('Registry::add argument $id must be different from $value');
100
        }
101
102 112
        $entry = new Entry($id, $value);
103 112
        $this->entries[$id . $paramName] = $entry;
104
105 112
        return $entry;
106
    }
107
108 4
    public function new(string $id, mixed ...$args): object
109
    {
110 4
        $entry = $this->entries[$id] ?? null;
111
112 4
        if ($entry) {
113
            /** @var mixed */
114 2
            $value = $entry->value();
115
116 2
            if (is_string($value)) {
117 2
                if (interface_exists($value)) {
118 1
                    return $this->new($value, ...$args);
119
                }
120
121 2
                if (class_exists($value)) {
122
                    /** @psalm-suppress MixedMethodCall */
123 2
                    return new $value(...$args);
124
                }
125
            }
126
        }
127
128 2
        if ($this->autowire && class_exists($id)) {
129
            /** @psalm-suppress MixedMethodCall */
130 1
            return new $id(...$args);
131
        }
132
133 1
        throw new NotFoundException('Cannot instantiate ' . $id);
134
    }
135
136 21
    public function getWithParamName(string $id, string $paramName): mixed
137
    {
138 21
        $paramName = $this->normalizeParameterName($paramName);
139
140
        // See if there's a entry with a bound parameter name:
141
        // e. g. '\Namespace\MyClass$myParameter'
142
        // If $paramName is emtpy an existing unbound entry should
143
        // be found on first try.
144 21
        return isset($this->entries[$id . $paramName]) ?
145 2
            $this->resolveEntry($this->entries[$id . $paramName]) :
146 21
            $this->get($id);
147
    }
148
149 61
    protected function resolveEntry(Entry $entry): mixed
150
    {
151
        /** @var mixed */
152 61
        $value = $entry->value();
153
154 61
        if ($entry->shouldReturnAsIs()) {
155 2
            return $value;
156
        }
157
158 60
        if (is_string($value)) {
159 54
            if (isset($this->entries[$value])) {
160 1
                return $this->get($value);
161
            }
162
163 54
            if (class_exists($value)) {
164 53
                $args = $entry->getArgs();
165
166 53
                if (isset($args)) {
167
                    // Don't autowire if $args are given
168 2
                    if ($args instanceof Closure) {
169 1
                        return $this->reifyAndReturn($entry, $this->fromArgsClosure($value, $args));
170
                    }
171
172 1
                    return $this->reifyAndReturn($entry, $this->fromArgsArray($value, $args));
173
                }
174
175 51
                return $this->reifyAndReturn($entry, $this->autowire($value));
176
            }
177
        }
178
179 49
        if ($value instanceof Closure) {
180
            // Get the instance from the registered closure
181 7
            $rf = new ReflectionFunction($value);
182 7
            $args = [];
183
184 7
            if (func_num_args() === 1) {
185 7
                $args = $this->resolveArgs($rf);
186
            }
187
188
            /** @var mixed */
189 7
            $result = $value(...$args);
190
191 7
            return $this->reifyAndReturn($entry, $result);
192
        }
193
194 48
        if (is_object($value)) {
195 47
            return $value;
196
        }
197
198 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

198
        throw new NotFoundException('Unresolvable id: ' . /** @scrutinizer ignore-type */ print_r($value, true));
Loading history...
199
    }
200
201 54
    protected function reifyAndReturn(Entry $entry, mixed $value): mixed
202
    {
203 54
        if ($entry->shouldReify()) {
204 53
            $entry->update($value);
205
        }
206
207 54
        return $value;
208
    }
209
210 24
    public function resolveParam(ReflectionParameter $param): mixed
211
    {
212 24
        $type = $param->getType();
213
214 24
        if ($type instanceof ReflectionNamedType) {
215
            try {
216 20
                return $this->getWithParamName($type->getName(), '$' . ltrim($param->getName(), '?'));
217 3
            } catch (NotFoundException $e) {
218 2
                if ($param->isDefaultValueAvailable()) {
219 1
                    return $param->getDefaultValue();
220
                }
221
222 1
                throw $e;
223
            }
224
        } else {
225 4
            if ($type) {
226 2
                throw new ContainerException(
227 2
                    "Autowiring does not support union or intersection types. Source: \n" .
228 2
                        $this->getParamInfo($param)
229 2
                );
230
            } else {
231 2
                throw new ContainerException(
232 2
                    "Autowired entities need to have typed constructor parameters. Source: \n" .
233 2
                        $this->getParamInfo($param)
234 2
                );
235
            }
236
        }
237
    }
238
239 6
    public function getParamInfo(ReflectionParameter $param): string
240
    {
241 6
        $type = $param->getType();
242 6
        $rf = $param->getDeclaringFunction();
243 6
        $rc = null;
244
245 6
        if ($rf instanceof ReflectionMethod) {
246 6
            $rc = $rf->getDeclaringClass();
247
        }
248
249 6
        return ($rc ? $rc->getName() . '::' : '') .
250 6
            ($rf->getName() . '(..., ') .
251 6
            ($type ? (string)$type . ' ' : '') .
252 6
            '$' . $param->getName() . ', ...)';
253
    }
254
255
    /** @param class-string $class */
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
256 57
    protected function autowire(string $class): object
257
    {
258 57
        $rc = new ReflectionClass($class);
259 57
        $constructor = $rc->getConstructor();
260 57
        $args = $this->resolveArgs($constructor);
261
262
        try {
263 54
            return $rc->newInstance(...$args);
264 2
        } catch (Throwable $e) {
265 2
            throw new ContainerException(
266 2
                'Autowiring unresolvable: ' . $class . ' Details: ' . $e->getMessage()
267 2
            );
268
        }
269
    }
270
271 60
    protected function resolveArgs(?ReflectionFunctionAbstract $rf): array
272
    {
273 60
        $args = [];
274
275 60
        if ($rf) {
276 15
            foreach ($rf->getParameters() as $param) {
277
                /** @var list<mixed> */
278 9
                $args[] = $this->resolveParam($param);
279
            }
280
        }
281
282 57
        return $args;
283
    }
284
285
    /** @param class-string $class */
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
286 1
    protected function fromArgsArray(string $class, array $args): object
287
    {
288
        /** @psalm-suppress MixedMethodCall */
289 1
        return new $class(...$args);
290
    }
291
292
    /** @param class-string $class */
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
293 1
    protected function fromArgsClosure(string $class, Closure $callback): object
294
    {
295 1
        $rf = new ReflectionFunction($callback);
296 1
        $args = $this->resolveArgs($rf);
297
298
        /** @psalm-suppress MixedMethodCall */
299 1
        return new $class(...$callback(...$args));
300
    }
301
302 112
    protected function normalizeParameterName(string $paramName): string
303
    {
304 112
        if (empty($paramName)) {
305 112
            return $paramName;
306
        }
307
308 22
        $paramName = trim($paramName);
309
310 22
        return str_starts_with($paramName, '$') ? $paramName : '$' . $paramName;
311
    }
312
}
313