Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.46% covered (warning)
88.46%
23 / 26
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Icons
88.46% covered (warning)
88.46%
23 / 26
66.67% covered (warning)
66.67%
4 / 6
14.30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 icon
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
6.09
 reset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 providers
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 failed
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray;
6
7use Celema\Container\Container;
8use Celema\Container\Resettable;
9use Cosray\Icons\Provider;
10use Override;
11
12/**
13 * Looks up icons in the registered providers and caches the markup. The
14 * service lives as long as the app, in a worker across requests: found
15 * icons stay cached up to a limit, missing ones only until the request
16 * ends, so a provider that failed for a moment is asked again.
17 */
18final class Icons implements Provider, Resettable
19{
20    private const int MAX_CACHED = 500;
21
22    /** @var array<string, string> */
23    private array $found = [];
24
25    /** @var array<string, string> */
26    private array $missing = [];
27
28    public function __construct(
29        private readonly Container $container,
30        private readonly Config $config,
31    ) {}
32
33    /** @param array<array-key, mixed> $args */
34    #[Override]
35    public function icon(string $id, array $args = []): string
36    {
37        $id = trim($id);
38
39        if ($id === '') {
40            return $this->failed('empty icon id');
41        }
42
43        $key = $this->key($id, $args);
44        $cached = $this->found[$key] ?? $this->missing[$key] ?? null;
45
46        if ($cached !== null) {
47            return $cached;
48        }
49
50        foreach ($this->providers() as $provider) {
51            $svg = $provider->icon($id, $args);
52
53            if ($svg === '') {
54                continue;
55            }
56
57            if (count($this->found) >= self::MAX_CACHED) {
58                // Drops the icon cached first; the cache only saves provider lookups.
59                unset($this->found[array_key_first($this->found)]);
60            }
61
62            return $this->found[$key] = $svg;
63        }
64
65        return $this->missing[$key] = $this->failed('icon not found: ' . $id);
66    }
67
68    /** Forgets the icons that were not found, at the end of every request. */
69    #[Override]
70    public function reset(): void
71    {
72        $this->missing = [];
73    }
74
75    /** @return iterable<Provider> */
76    private function providers(): iterable
77    {
78        $tag = $this->container->tag(Provider::class);
79
80        foreach ($tag->entries() as $id) {
81            $provider = $tag->get($id);
82
83            if ($provider instanceof Provider) {
84                yield $provider;
85            }
86        }
87    }
88
89    /** @param array<array-key, mixed> $args */
90    private function key(string $id, array $args): string
91    {
92        return hash('xxh3', $id . "\x1f" . serialize($args));
93    }
94
95    private function failed(string $message): string
96    {
97        if (!$this->config->debug()) {
98            return '';
99        }
100
101        return sprintf('<!-- %s -->', escape($message));
102    }
103}