Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
212 / 212
96.39% covered (success)
96.39%
160 / 166
49.36% covered (danger)
49.36%
77 / 156
84.00% covered (warning)
84.00%
21 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Container
100.00% covered (success)
100.00%
212 / 212
96.39% covered (success)
96.39%
160 / 166
49.36% covered (danger)
49.36%
77 / 156
100.00% covered (success)
100.00%
25 / 25
1046.52
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 scope
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 reset
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 has
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
5 / 5
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 entries
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
6 / 6
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
4.25
 entry
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get
100.00% covered (success)
100.00%
30 / 30
95.24% covered (success)
95.24%
20 / 21
66.67% covered (warning)
66.67%
8 / 12
100.00% covered (success)
100.00%
1 / 1
12.00
 definition
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 add
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addEntry
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tag
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
9 / 9
57.14% covered (warning)
57.14%
4 / 7
100.00% covered (success)
100.00%
1 / 1
6.97
 new
100.00% covered (success)
100.00%
11 / 11
86.67% covered (warning)
86.67%
13 / 15
21.05% covered (danger)
21.05%
4 / 19
100.00% covered (success)
100.00%
1 / 1
23.71
 resolveEntry
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
9 / 9
57.14% covered (warning)
57.14%
4 / 7
100.00% covered (success)
100.00%
1 / 1
6.97
 resolutionContainers
100.00% covered (success)
100.00%
5 / 5
77.78% covered (warning)
77.78%
7 / 9
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
4.25
 materialize
100.00% covered (success)
100.00%
35 / 35
95.24% covered (success)
95.24%
20 / 21
39.13% covered (danger)
39.13%
9 / 23
100.00% covered (success)
100.00%
1 / 1
32.55
 applyCalls
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
4 / 4
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
2.15
 resetScope
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
12 / 12
13.89% covered (danger)
13.89%
5 / 36
100.00% covered (success)
100.00%
1 / 1
20.96
 resetIfNeeded
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 trackAndReturn
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
5 / 5
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 findEntry
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 taggedParent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
6 / 6
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 root
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 assertMutable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 seal
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
4 / 4
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
2.15
 isRoot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
5 / 5
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Container;
6
7use Celema\Container\Exception\ContainerException;
8use Celema\Container\Exception\NotFoundException;
9use Celema\Wire\CallableResolver;
10use Celema\Wire\Creator;
11use Celema\Wire\Exception\WireException;
12use Celema\Wire\WireContainer;
13use Closure;
14use Override;
15use Psr\Container\ContainerExceptionInterface;
16use Psr\Container\ContainerInterface as PsrContainer;
17use Throwable;
18
19/** @psalm-api */
20class Container implements WireContainer
21{
22    protected Creator $creator;
23    protected readonly ?PsrContainer $wrappedContainer;
24    protected bool $sealed = false;
25
26    /** @var array<string, Entry> */
27    protected array $entries = [];
28
29    /** @var array<string, mixed> */
30    protected array $instances = [];
31
32    /** @var array<non-empty-string, self> */
33    protected array $tags = [];
34
35    /** @var array<int, Resettable> */
36    protected array $usedResettables = [];
37
38    public function __construct(
39        public readonly bool $autowire = true,
40        ?PsrContainer $container = null,
41        protected readonly string $tag = '',
42        protected readonly ?Container $parent = null,
43        protected readonly bool $isScope = false,
44    ) {
45        if ($container) {
46            $this->wrappedContainer = $container;
47            $this->add(PsrContainer::class, $container);
48            $this->add($container::class, $container);
49        } else {
50            $this->wrappedContainer = null;
51            $this->add(PsrContainer::class, $this);
52        }
53        $this->add(Container::class, $this);
54        $this->creator = new Creator($this);
55    }
56
57    public function scope(): Container
58    {
59        $root = $this->root();
60
61        if (!$root->sealed) {
62            $root->seal();
63        }
64
65        return new self(
66            autowire: $root->autowire,
67            parent: $root,
68            isScope: true,
69        );
70    }
71
72    public function reset(): void
73    {
74        if (!$this->isScope) {
75            return;
76        }
77
78        $resetIds = [];
79        $this->resetScope($resetIds);
80    }
81
82    #[Override]
83    public function has(string $id): bool
84    {
85        return (
86            isset($this->entries[$id])
87                || $this->parent?->has($id)
88                || $this->wrappedContainer?->has($id)
89        );
90    }
91
92    /** @return list<string> */
93    public function entries(bool $includeContainer = false): array
94    {
95        $inherited = $this->taggedParent()?->entries(true) ?? [];
96        $keys = $inherited === []
97            ? array_keys($this->entries)
98            : array_values(array_unique([...array_keys($this->entries), ...$inherited]));
99
100        if ($includeContainer) {
101            return $keys;
102        }
103
104        return array_values(array_filter(
105            $keys,
106            static fn($item) => $item !== PsrContainer::class && !is_subclass_of($item, PsrContainer::class),
107        ));
108    }
109
110    public function entry(string $id): Entry
111    {
112        $entry = $this->entries[$id] ?? null;
113
114        if ($entry !== null) {
115            return $entry;
116        }
117
118        $parent = $this->taggedParent();
119
120        if ($parent !== null) {
121            return $parent->entry($id);
122        }
123
124        throw new NotFoundException('Unresolvable entry - id: ' . $id);
125    }
126
127    #[Override]
128    public function get(string $id): mixed
129    {
130        try {
131            if (array_key_exists($id, $this->instances)) {
132                return $this->trackAndReturn($this->instances[$id]);
133            }
134
135            $resolved = $this->findEntry($id);
136
137            if ($resolved !== null) {
138                return $this->trackAndReturn(
139                    $this->resolveEntry(
140                        entryOwner: $resolved[0],
141                        entry: $resolved[1],
142                        id: $id,
143                        requester: $this,
144                    ),
145                );
146            }
147
148            $wrappedContainer = $this->root()->wrappedContainer;
149
150            if ($wrappedContainer?->has($id)) {
151                return $this->trackAndReturn($wrappedContainer->get($id));
152            }
153
154            // Autowiring: $id does not exists as an entry in the container
155            if ($this->autowire && class_exists($id)) {
156                return $this->trackAndReturn($this->creator->create($id));
157            }
158        } catch (WireException $e) {
159            throw new NotFoundException(
160                'Unresolvable id: ' . $id . ' - Details: ' . $e->getMessage(),
161                previous: $e,
162            );
163        } catch (ContainerExceptionInterface $e) {
164            throw $e;
165        } catch (Throwable $e) {
166            throw new ContainerException(
167                'Unresolvable id: ' . $id . ' - ' . $e->getMessage(),
168                previous: $e,
169            );
170        }
171
172        throw new NotFoundException('Unresolvable id: ' . $id);
173    }
174
175    #[Override]
176    public function definition(string $id): mixed
177    {
178        $resolved = $this->findEntry($id);
179        $entry = $resolved[1] ?? null;
180
181        if ($entry !== null) {
182            return $entry->definition();
183        }
184
185        throw new NotFoundException('Unresolvable definition - id: ' . $id);
186    }
187
188    /**
189     * @param non-empty-string $id
190     */
191    public function add(
192        string $id,
193        mixed $value = null,
194    ): Entry {
195        $this->assertMutable();
196        $entry = new Entry($id, $value ?? $id);
197        $this->entries[$id] = $entry;
198        unset($this->instances[$id]);
199
200        return $entry;
201    }
202
203    public function addEntry(
204        Entry $entry,
205    ): Entry {
206        $this->assertMutable();
207        $this->entries[$entry->id] = $entry;
208        unset($this->instances[$entry->id]);
209
210        return $entry;
211    }
212
213    /** @param non-empty-string $tag */
214    public function tag(string $tag): Container
215    {
216        if (isset($this->tags[$tag])) {
217            return $this->tags[$tag];
218        }
219
220        if ($this->isRoot() && $this->sealed) {
221            throw new ContainerException('The root container is sealed after scope() was called');
222        }
223
224        $parent = $this;
225        $isScope = false;
226
227        if ($this->isScope) {
228            $root = $this->root();
229            $parent = $root->tags[$tag] ?? $root;
230            $isScope = true;
231        }
232
233        $this->tags[$tag] = new self(
234            autowire: $this->autowire,
235            tag: $tag,
236            parent: $parent,
237            isScope: $isScope,
238        );
239
240        return $this->tags[$tag];
241    }
242
243    public function new(string $id, mixed ...$args): object
244    {
245        $entry = $this->entries[$id] ?? null;
246
247        if ($entry) {
248            /** @var mixed $value */
249            $value = $entry->definition();
250
251            if (is_string($value)) {
252                if (interface_exists($value)) {
253                    return $this->new($value, ...$args);
254                }
255
256                if (class_exists($value)) {
257                    /** @psalm-suppress MixedMethodCall */
258                    return new $value(...$args);
259                }
260            }
261        }
262
263        if (class_exists($id)) {
264            /** @psalm-suppress MixedMethodCall */
265            return new $id(...$args);
266        }
267
268        throw new NotFoundException('Cannot instantiate ' . $id);
269    }
270
271    protected function resolveEntry(
272        Container $entryOwner,
273        Entry $entry,
274        string $id,
275        Container $requester,
276    ): mixed {
277        if ($entry->shouldReturnValue()) {
278            return $entry->definition();
279        }
280
281        [$cacheContainer, $resolutionContext] = $this->resolutionContainers(
282            $entry,
283            $entryOwner,
284            $requester,
285        );
286
287        if ($cacheContainer !== null && array_key_exists($id, $cacheContainer->instances)) {
288            return $cacheContainer->instances[$id];
289        }
290
291        // materialize() intentionally returns mixed because container entries may resolve to any value type.
292        /** @psalm-suppress MixedAssignment */
293        $result = $this->materialize($entry, $resolutionContext);
294
295        if ($cacheContainer !== null) {
296            $cacheContainer->instances[$id] = $result;
297        }
298
299        return $result;
300    }
301
302    /**
303     * @return array{0: null|Container, 1: Container}
304     */
305    protected function resolutionContainers(
306        Entry $entry,
307        Container $entryOwner,
308        Container $requester,
309    ): array {
310        return match ($entry->getLifetime()) {
311            Lifetime::Shared => [$entryOwner, $entryOwner],
312            Lifetime::Scoped => [$requester, $requester],
313            Lifetime::Transient => [null, $requester],
314        };
315    }
316
317    protected function materialize(Entry $entry, Container $context): mixed
318    {
319        /** @var mixed $value */
320        $value = $entry->definition();
321
322        if (is_string($value)) {
323            if (class_exists($value)) {
324                $constructor = $entry->getConstructor();
325                $args = $entry->getArgs();
326
327                if (isset($args)) {
328                    // Don't autowire if $args are given
329                    if ($args instanceof Closure) {
330                        $args = $args(...new CallableResolver($context->creator)->resolve($args));
331
332                        return $this->applyCalls($entry, $context->creator->create($value, $args), $context);
333                    }
334
335                    return $this->applyCalls(
336                        $entry,
337                        $context->creator->create(
338                            $value,
339                            predefinedArgs: $args,
340                            constructor: $constructor ?? '',
341                        ),
342                        $context,
343                    );
344                }
345
346                return $this->applyCalls(
347                    $entry,
348                    $context->creator->create($value, constructor: $constructor ?? ''),
349                    $context,
350                );
351            }
352
353            if ($context->has($value)) {
354                return $context->get($value);
355            }
356        }
357
358        if ($value instanceof Closure) {
359            $args = $entry->getArgs();
360
361            if (is_null($args)) {
362                $args = new CallableResolver($context->creator)->resolve($value);
363            } elseif ($args instanceof Closure) {
364                $args = $args();
365            }
366
367            return $this->applyCalls($entry, $value(...$args), $context);
368        }
369
370        if (is_object($value)) {
371            return $value;
372        }
373
374        throw new NotFoundException('Unresolvable id: ' . (string) $value);
375    }
376
377    protected function applyCalls(Entry $entry, mixed $value, Container $context): mixed
378    {
379        foreach ($entry->getCalls() as $call) {
380            $methodToResolve = $call->method;
381
382            /** @var callable */
383            $callable = [$value, $methodToResolve];
384            $args = new CallableResolver($context->creator)->resolve($callable, $call->args);
385            $callable(...$args);
386        }
387
388        return $value;
389    }
390
391    /**
392     * @param array<int, true> $resetIds
393     */
394    protected function resetScope(array &$resetIds): void
395    {
396        // $instances stores mixed values by design, so foreach assigns mixed to $instance.
397        /** @psalm-suppress MixedAssignment */
398        foreach ($this->instances as $instance) {
399            $this->resetIfNeeded($instance, $resetIds);
400        }
401
402        foreach ($this->usedResettables as $usedResettable) {
403            $this->resetIfNeeded($usedResettable, $resetIds);
404        }
405
406        foreach ($this->tags as $tagContainer) {
407            if (!$tagContainer->isScope) {
408                continue;
409            }
410
411            $tagContainer->resetScope($resetIds);
412        }
413
414        $this->instances = [];
415        $this->entries = [];
416        $this->tags = [];
417        $this->usedResettables = [];
418        $this->add(PsrContainer::class, $this);
419        $this->add(Container::class, $this);
420    }
421
422    /**
423     * @param array<int, true> $resetIds
424     */
425    protected function resetIfNeeded(mixed $value, array &$resetIds): void
426    {
427        if (!$value instanceof Resettable) {
428            return;
429        }
430
431        $objectId = spl_object_id($value);
432
433        if (isset($resetIds[$objectId])) {
434            return;
435        }
436
437        $resetIds[$objectId] = true;
438        $value->reset();
439    }
440
441    protected function trackAndReturn(mixed $value): mixed
442    {
443        if ($this->isScope && $value instanceof Resettable) {
444            $this->usedResettables[spl_object_id($value)] = $value;
445        }
446
447        return $value;
448    }
449
450    /** @return array{Container, Entry}|null */
451    protected function findEntry(string $id): ?array
452    {
453        $entry = $this->entries[$id] ?? null;
454
455        if ($entry !== null) {
456            return [$this, $entry];
457        }
458
459        return $this->parent?->findEntry($id);
460    }
461
462    /**
463     * The tag container holding the registrations this one inherits.
464     *
465     * A scope's tag container starts out empty — tag() links it to the root's
466     * container for the same tag, and the registrations live there. Only a
467     * same-tag parent counts: a tag container on a non-scope container has the
468     * owning container as its parent, and inheriting from that would list every
469     * service in the container under the tag.
470     */
471    protected function taggedParent(): ?Container
472    {
473        return $this->tag !== '' && $this->parent?->tag === $this->tag ? $this->parent : null;
474    }
475
476    protected function root(): Container
477    {
478        $container = $this;
479
480        while ($container->parent !== null) {
481            $container = $container->parent;
482        }
483
484        return $container;
485    }
486
487    protected function assertMutable(): void
488    {
489        if ($this->sealed) {
490            throw new ContainerException('The root container is sealed after scope() was called');
491        }
492    }
493
494    protected function seal(): void
495    {
496        $this->sealed = true;
497
498        foreach ($this->tags as $tagContainer) {
499            $tagContainer->seal();
500        }
501    }
502
503    protected function isRoot(): bool
504    {
505        return $this->parent === null && $this->tag === '' && !$this->isScope;
506    }
507}