Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
239 / 239
96.70% covered (success)
96.70%
176 / 182
47.22% covered (danger)
47.22%
85 / 180
83.33% covered (warning)
83.33%
20 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Container
100.00% covered (success)
100.00%
239 / 239
96.70% covered (success)
96.70%
176 / 182
47.22% covered (danger)
47.22%
85 / 180
100.00% covered (success)
100.00%
24 / 24
1336.31
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%
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
 has
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
7 / 7
37.50% covered (danger)
37.50%
3 / 8
100.00% covered (success)
100.00%
1 / 1
7.91
 entries
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 entry
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
 get
100.00% covered (success)
100.00%
32 / 32
96.00% covered (success)
96.00%
24 / 25
47.62% covered (danger)
47.62%
10 / 21
100.00% covered (success)
100.00%
1 / 1
28.39
 instance
100.00% covered (success)
100.00%
6 / 6
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%
15 / 15
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 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%
19 / 19
100.00% covered (success)
100.00%
15 / 15
24.14% covered (danger)
24.14%
7 / 29
100.00% covered (success)
100.00%
1 / 1
35.94
 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%
40 / 40
95.45% covered (success)
95.45%
21 / 22
66.67% covered (warning)
66.67%
10 / 15
100.00% covered (success)
100.00%
1 / 1
13.70
 checkedArgs
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
 applyCalls
100.00% covered (success)
100.00%
9 / 9
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
 resetScope
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
10 / 10
18.52% covered (danger)
18.52%
5 / 27
100.00% covered (success)
100.00%
1 / 1
12.66
 resetIfNeeded
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 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%
7 / 7
100.00% covered (success)
100.00%
10 / 10
71.43% covered (warning)
71.43%
5 / 7
100.00% covered (success)
100.00%
1 / 1
5.58
 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%
6 / 6
100.00% covered (success)
100.00%
9 / 9
16.67% covered (danger)
16.67%
2 / 12
100.00% covered (success)
100.00%
1 / 1
13.26
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Container;
6
7use Celema\Container\Exception\ContainerException;
8use Celema\Container\Exception\NotFoundException;
9use Celema\Container\Exception\ResetFailed;
10use Celema\Wire\CallableResolver;
11use Celema\Wire\Creator;
12use Celema\Wire\Exception\WireException;
13use Closure;
14use Override;
15use Psr\Container\ContainerExceptionInterface;
16use Psr\Container\ContainerInterface as PsrContainer;
17use Throwable;
18
19/** @psalm-api */
20class Container implements PsrContainer
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    /** The root tag whose registrations a scope's tag container lists and resolves. */
39    protected ?Container $inherits = null;
40
41    public function __construct(
42        public readonly bool $autowire = true,
43        ?PsrContainer $container = null,
44        protected readonly string $tag = '',
45        protected readonly ?Container $parent = null,
46        protected readonly bool $isScope = false,
47    ) {
48        if ($container) {
49            $this->wrappedContainer = $container;
50            $this->add(PsrContainer::class, $container);
51            $this->add($container::class, $container);
52        } else {
53            $this->wrappedContainer = null;
54            $this->add(PsrContainer::class, $this);
55        }
56        $this->add(Container::class, $this);
57        $this->creator = new Creator($this);
58    }
59
60    public function scope(): Container
61    {
62        $root = $this->root();
63
64        if (!$root->sealed) {
65            $root->seal();
66        }
67
68        return new self(
69            autowire: $root->autowire,
70            parent: $root,
71            isScope: true,
72        );
73    }
74
75    public function reset(): void
76    {
77        if (!$this->isScope) {
78            return;
79        }
80
81        $resetIds = [];
82        $failures = [];
83        $this->resetScope($resetIds, $failures);
84
85        if ($failures !== []) {
86            throw new ResetFailed($failures);
87        }
88    }
89
90    #[Override]
91    public function has(string $id): bool
92    {
93        return (
94            isset($this->entries[$id])
95                || isset($this->inherits?->entries[$id])
96                || $this->parent?->has($id)
97                || $this->wrappedContainer?->has($id)
98        );
99    }
100
101    /** @return list<string> */
102    public function entries(bool $includeContainer = false): array
103    {
104        $inherited = $this->inherits?->entries(true) ?? [];
105        $keys = $inherited === []
106            ? array_keys($this->entries)
107            : array_values(array_unique([...array_keys($this->entries), ...$inherited]));
108
109        if ($includeContainer) {
110            return $keys;
111        }
112
113        return array_values(array_filter(
114            $keys,
115            static fn(string $item): bool => $item !== PsrContainer::class
116            && !is_subclass_of($item, PsrContainer::class),
117        ));
118    }
119
120    public function entry(string $id): Entry
121    {
122        return (
123            $this->entries[$id]
124                ?? $this->inherits?->entries[$id]
125                ?? throw new NotFoundException('Unresolvable entry - id: ' . $id)
126        );
127    }
128
129    #[Override]
130    public function get(string $id): mixed
131    {
132        try {
133            if (array_key_exists($id, $this->instances)) {
134                return $this->trackAndReturn($this->instances[$id]);
135            }
136
137            $resolved = $this->findEntry($id);
138
139            if ($resolved !== null) {
140                return $this->trackAndReturn(
141                    $this->resolveEntry(
142                        entryOwner: $resolved[0],
143                        entry: $resolved[1],
144                        id: $id,
145                        requester: $this,
146                    ),
147                );
148            }
149
150            if ($this->tag !== '' && $this->parent !== null) {
151                // Ids that were not tagged belong to the container that owns the
152                // tag. Resolving them there keeps their lifetime caches and the
153                // overrides of an enclosing scope.
154                return $this->parent->get($id);
155            }
156
157            $wrappedContainer = $this->root()->wrappedContainer;
158
159            if ($wrappedContainer?->has($id)) {
160                return $this->trackAndReturn($wrappedContainer->get($id));
161            }
162
163            // Autowiring: $id does not exists as an entry in the container
164            if ($this->autowire && class_exists($id)) {
165                return $this->trackAndReturn($this->creator->create($id));
166            }
167        } catch (WireException $e) {
168            throw new NotFoundException(
169                'Unresolvable id: ' . $id . ' - Details: ' . $e->getMessage(),
170                previous: $e,
171            );
172        } catch (ContainerExceptionInterface $e) {
173            throw $e;
174        } catch (Throwable $e) {
175            throw new ContainerException(
176                'Unresolvable id: ' . $id . ' - ' . $e->getMessage(),
177                previous: $e,
178            );
179        }
180
181        throw new NotFoundException('Unresolvable id: ' . $id);
182    }
183
184    /**
185     * @template T of object
186     * @param class-string<T> $class
187     * @return T
188     */
189    public function instance(string $class): object
190    {
191        /** @var mixed $value */
192        $value = $this->get($class);
193
194        if (!$value instanceof $class) {
195            throw new ContainerException(
196                "Entry '{$class}' resolves to " . get_debug_type($value) . ', not an instance of it',
197            );
198        }
199
200        return $value;
201    }
202
203    /**
204     * @param non-empty-string $id
205     */
206    public function add(
207        string $id,
208        mixed $value = null,
209    ): Entry {
210        $this->assertMutable();
211        $entry = new Entry($id, $value ?? $id);
212        $this->entries[$id] = $entry;
213        unset($this->instances[$id]);
214
215        return $entry;
216    }
217
218    public function addEntry(
219        Entry $entry,
220    ): Entry {
221        $this->assertMutable();
222        $this->entries[$entry->id] = $entry;
223        unset($this->instances[$entry->id]);
224
225        return $entry;
226    }
227
228    /** @param non-empty-string $tag */
229    public function tag(string $tag): Container
230    {
231        if (isset($this->tags[$tag])) {
232            return $this->tags[$tag];
233        }
234
235        if ($this->sealed) {
236            // Reading a tag that was never registered is not a structural change.
237            // The empty view is not stored, so the sealed container stays as it is.
238            $view = new self(autowire: $this->autowire, tag: $tag, parent: $this);
239            $view->seal();
240
241            return $view;
242        }
243
244        $tagContainer = new self(
245            autowire: $this->autowire,
246            tag: $tag,
247            parent: $this,
248            isScope: $this->isScope,
249        );
250
251        // A scope's tag lists the root tag's registrations and adds its own,
252        // while untagged ids resolve through the scope.
253        if ($this->isScope) {
254            $tagContainer->inherits = $this->root()->tags[$tag] ?? null;
255        }
256
257        return $this->tags[$tag] = $tagContainer;
258    }
259
260    public function new(string $id, mixed ...$args): object
261    {
262        $entry = $this->entries[$id] ?? null;
263
264        if ($entry) {
265            /** @var mixed $value */
266            $value = $entry->definition();
267
268            if (is_string($value)) {
269                if (interface_exists($value)) {
270                    return $this->new($value, ...$args);
271                }
272
273                if (class_exists($value)) {
274                    // Psalm cannot know the constructor of a class named at runtime.
275                    /** @psalm-suppress MixedMethodCall */
276                    return new $value(...$args);
277                }
278            }
279        }
280
281        if (class_exists($id)) {
282            // Psalm cannot know the constructor of a class named at runtime.
283            /** @psalm-suppress MixedMethodCall */
284            return new $id(...$args);
285        }
286
287        throw new NotFoundException('Cannot instantiate ' . $id);
288    }
289
290    protected function resolveEntry(
291        Container $entryOwner,
292        Entry $entry,
293        string $id,
294        Container $requester,
295    ): mixed {
296        if ($entry->shouldReturnValue()) {
297            return $entry->definition();
298        }
299
300        if ($entry->getLifetime() === Lifetime::Scoped && !$requester->isScope && $requester->sealed) {
301            throw new ContainerException(
302                "Scoped entry '{$id}' cannot be resolved by the root container after scope() was called, "
303                    . 'as every scope would share that instance. It is most likely a dependency of a shared '
304                    . 'entry: make that entry scoped or transient, or resolve the entry from a scope.',
305            );
306        }
307
308        [$cacheContainer, $resolutionContext] = $this->resolutionContainers(
309            $entry,
310            $entryOwner,
311            $requester,
312        );
313
314        if ($cacheContainer !== null && array_key_exists($id, $cacheContainer->instances)) {
315            return $cacheContainer->instances[$id];
316        }
317
318        // materialize() intentionally returns mixed because container entries may resolve to any value type.
319        /** @psalm-suppress MixedAssignment */
320        $result = $this->materialize($entry, $resolutionContext);
321
322        if ($cacheContainer !== null) {
323            $cacheContainer->instances[$id] = $result;
324        }
325
326        return $result;
327    }
328
329    /**
330     * @return array{0: null|Container, 1: Container}
331     */
332    protected function resolutionContainers(
333        Entry $entry,
334        Container $entryOwner,
335        Container $requester,
336    ): array {
337        return match ($entry->getLifetime()) {
338            Lifetime::Shared => [$entryOwner, $entryOwner],
339            Lifetime::Scoped => [$requester, $requester],
340            Lifetime::Transient => [null, $requester],
341        };
342    }
343
344    protected function materialize(Entry $entry, Container $context): mixed
345    {
346        /** @var mixed $value */
347        $value = $entry->definition();
348
349        if (is_string($value)) {
350            if (class_exists($value)) {
351                $constructor = $entry->getConstructor();
352                $args = $entry->getArgs();
353
354                if (isset($args)) {
355                    // Don't autowire if $args are given
356                    if ($args instanceof Closure) {
357                        $args = self::checkedArgs(
358                            $entry,
359                            $args(...new CallableResolver($context->creator)->resolve($args)),
360                        );
361                    }
362
363                    return $this->applyCalls(
364                        $entry,
365                        $context->creator->create(
366                            $value,
367                            predefinedArgs: $args,
368                            constructor: $constructor ?? '',
369                        ),
370                        $context,
371                    );
372                }
373
374                return $this->applyCalls(
375                    $entry,
376                    $context->creator->create($value, constructor: $constructor ?? ''),
377                    $context,
378                );
379            }
380
381            if ($context->has($value)) {
382                return $context->get($value);
383            }
384
385            throw new NotFoundException('Unresolvable id: ' . $value);
386        }
387
388        if ($value instanceof Closure) {
389            $args = $entry->getArgs();
390
391            if (is_null($args)) {
392                $args = new CallableResolver($context->creator)->resolve($value);
393            } elseif ($args instanceof Closure) {
394                $args = self::checkedArgs($entry, $args());
395            }
396
397            return $this->applyCalls($entry, $value(...$args), $context);
398        }
399
400        if (is_object($value)) {
401            return $value;
402        }
403
404        throw new NotFoundException(
405            "Unresolvable id: {$entry->id} - cannot resolve a definition of type " . get_debug_type($value),
406        );
407    }
408
409    /** @return array<array-key, mixed> */
410    private static function checkedArgs(Entry $entry, mixed $args): array
411    {
412        if (!is_array($args)) {
413            throw new ContainerException(
414                "The args closure of entry '{$entry->id}' must return an array, " . get_debug_type($args) . ' returned',
415            );
416        }
417
418        return $args;
419    }
420
421    protected function applyCalls(Entry $entry, mixed $value, Container $context): mixed
422    {
423        foreach ($entry->getCalls() as $call) {
424            $callable = [$value, $call->method];
425
426            if (!is_callable($callable)) {
427                throw new ContainerException(
428                    "Entry '{$entry->id}' cannot call {$call->method}() on " . get_debug_type($value),
429                );
430            }
431
432            $args = new CallableResolver($context->creator)->resolve($callable, $call->args);
433            $callable(...$args);
434        }
435
436        return $value;
437    }
438
439    /**
440     * Attempts every reset hook and clears the scope even if hooks fail; the
441     * failures are collected for the caller.
442     *
443     * @param array<int, true> $resetIds
444     * @param list<Throwable> $failures
445     */
446    protected function resetScope(array &$resetIds, array &$failures): void
447    {
448        // $instances stores mixed values by design, so foreach assigns mixed to $instance.
449        /** @psalm-suppress MixedAssignment */
450        foreach ($this->instances as $instance) {
451            $this->resetIfNeeded($instance, $resetIds, $failures);
452        }
453
454        foreach ($this->usedResettables as $usedResettable) {
455            $this->resetIfNeeded($usedResettable, $resetIds, $failures);
456        }
457
458        // A scope's tags inherit isScope from it, so all of them are scopes.
459        foreach ($this->tags as $tagContainer) {
460            $tagContainer->resetScope($resetIds, $failures);
461        }
462
463        $this->instances = [];
464        $this->entries = [];
465        $this->tags = [];
466        $this->usedResettables = [];
467        $this->add(PsrContainer::class, $this);
468        $this->add(Container::class, $this);
469    }
470
471    /**
472     * @param array<int, true> $resetIds
473     * @param list<Throwable> $failures
474     */
475    protected function resetIfNeeded(mixed $value, array &$resetIds, array &$failures): void
476    {
477        if (!$value instanceof Resettable) {
478            return;
479        }
480
481        $objectId = spl_object_id($value);
482
483        if (isset($resetIds[$objectId])) {
484            return;
485        }
486
487        $resetIds[$objectId] = true;
488
489        try {
490            $value->reset();
491        } catch (Throwable $e) {
492            $failures[] = $e;
493        }
494    }
495
496    protected function trackAndReturn(mixed $value): mixed
497    {
498        if ($this->isScope && $value instanceof Resettable) {
499            $this->usedResettables[spl_object_id($value)] = $value;
500        }
501
502        return $value;
503    }
504
505    /** @return array{Container, Entry}|null */
506    protected function findEntry(string $id): ?array
507    {
508        $entry = $this->entries[$id] ?? null;
509
510        if ($entry !== null) {
511            return [$this, $entry];
512        }
513
514        $inherits = $this->inherits;
515
516        if ($inherits !== null && isset($inherits->entries[$id])) {
517            // Inherited shared entries are cached by the root tag that defines them.
518            return [$inherits, $inherits->entries[$id]];
519        }
520
521        // A tag container only finds tagged entries; get() hands other ids to
522        // the container owning the tag.
523        return $this->tag === '' ? $this->parent?->findEntry($id) : null;
524    }
525
526    protected function root(): Container
527    {
528        $container = $this;
529
530        while ($container->parent !== null) {
531            $container = $container->parent;
532        }
533
534        return $container;
535    }
536
537    protected function assertMutable(): void
538    {
539        if ($this->sealed) {
540            throw new ContainerException('The root container is sealed after scope() was called');
541        }
542    }
543
544    protected function seal(): void
545    {
546        $this->sealed = true;
547
548        // Scoped entries resolved before the first scope must not outlive it:
549        // the sealed container rejects them from now on.
550        foreach ($this->entries as $id => $entry) {
551            if ($entry->getLifetime() === Lifetime::Scoped) {
552                unset($this->instances[$id]);
553            }
554        }
555
556        foreach ($this->tags as $tagContainer) {
557            $tagContainer->seal();
558        }
559    }
560}