Passed
Push — main ( d61a4b...a3ea10 )
by Oscar
12:17
created

IconifyLoader::loadIcon()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 22
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 5

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 11
dl 0
loc 22
ccs 12
cts 12
cp 1
rs 9.6111
c 1
b 0
f 0
cc 5
nc 5
nop 2
crap 5
1
<?php
2
3
/*
4
 * This file is part of ocubom/twig-svg-extension
5
 *
6
 * © Oscar Cubo Medina <https://ocubom.github.io>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Ocubom\Twig\Extension\Svg\Provider\Iconify;
13
14
use Iconify\JSONTools\Collection;
15
use Iconify\JSONTools\SVG as Icon;
16
use Ocubom\Twig\Extension\Svg\Exception\LoaderException;
17
use Ocubom\Twig\Extension\Svg\Exception\LogicException;
18
use Ocubom\Twig\Extension\Svg\Loader\LoaderInterface;
19
use Ocubom\Twig\Extension\Svg\Svg;
20
use Ocubom\Twig\Extension\Svg\Util\PathCollection;
21
use Symfony\Component\Filesystem\Filesystem;
22
use Symfony\Component\Filesystem\Path;
23
use Symfony\Component\OptionsResolver\Options;
24
use Symfony\Component\OptionsResolver\OptionsResolver;
25
26
use function BenTools\IterableFunctions\iterable_to_array;
27
use function Ocubom\Twig\Extension\is_string;
28
29
class IconifyLoader implements LoaderInterface
30
{
31
    private PathCollection $searchPath;
32
    private ?string $cacheDir;
33
    private static Filesystem $fs;
34
    private const CACHE_BASEDIR = 'iconify';
35
    private const CACHE_EXTENSION = 'php';
36
37 18
    public function __construct(PathCollection $searchPath, iterable $options = null)
38
    {
39 18
        self::$fs = new Filesystem();
40
41 18
        $this->searchPath = $searchPath;
42
43
        // Parse options
44 18
        $options = static::configureOptions()
45 18
            ->resolve(iterable_to_array($options ?? /* @scrutinizer ignore-type */ []));
46 18
        $this->cacheDir = Path::canonicalize($options['cache_dir'] ?? '');
47 18
        if ('' === $this->cacheDir) {
48
            $this->cacheDir = null; // @codeCoverageIgnore
49
        } else {
50
            // Ensure cache dir is inside prefixed subdir
51 18
            if (self::CACHE_BASEDIR !== Path::getFilenameWithoutExtension($this->cacheDir)) {
52 18
                $this->cacheDir = Path::join($this->cacheDir, self::CACHE_BASEDIR);
53
            }
54
        }
55
    }
56
57 8
    public function resolve(string $ident, iterable $options = null): Svg
58
    {
59
        // Split ident
60 8
        $tokens = preg_split('@[/:\-]+@Uis', $ident);
61 8
        $count = count($tokens);
62 8
        for ($idx = 1; $idx < $count; ++$idx) {
63 8
            $icon = $this->loadIcon(
64 8
                join('-', array_slice($tokens, 0, $idx)),
65 8
                join('-', array_slice($tokens, $idx - $count))
66 8
            );
67
68 8
            if ($icon) {
69 4
                return new Svg($icon->getSVG(iterable_to_array($options ?? [])));
70
            }
71
        }
72
73 4
        throw new LoaderException($ident, new \ReflectionClass($this));
74
    }
75
76 8
    private function loadIcon(string $prefix, string $name): ?Icon
77
    {
78 8
        foreach ($this->getCollectionPaths($prefix) as $path) {
79 8
            if (!self::$fs->exists($path)) {
80 4
                continue;
81
            }
82
83 6
            $collection = new Collection();
84 6
            if (!$collection->loadFromFile($path, null, $this->getCacheFile($prefix))) {
85 1
                continue;
86
            }
87
88 5
            $data = $collection->getIconData($name);
89 5
            if (!$data) {
90 1
                continue;
91
            }
92
93 4
            return new Icon($data);
94
        }
95
96
        // Unable to find icon
97 4
        return null;
98
    }
99
100 6
    private function getCacheFile(string $collection): ?string
101
    {
102 6
        if (null === $this->cacheDir) {
103
            return null; // @codeCoverageIgnore
104
        }
105
106 6
        $path = Path::changeExtension(
107 6
            Path::join($this->cacheDir, $collection),
108 6
            self::CACHE_EXTENSION
109 6
        );
110 6
        if (!Path::isBasePath($this->cacheDir, $path)) {
111
            // @codeCoverageIgnoreStart
112
            throw new LogicException(sprintf(
113
                'The generated cache path `%s` is outside the base cache directory `%s`.',
114
                $path,
115
                $this->cacheDir
116
            ));
117
            // @codeCoverageIgnoreEnd
118
        }
119
120
        // Ensure cache dir exists
121 6
        self::$fs->mkdir($this->cacheDir, 0755);
122
123 6
        return $path;
124
    }
125
126
    /**
127
     * @psalm-return \Generator<int, string, mixed, void>
128
     */
129 8
    private function getCollectionPaths(string $name): \Generator
130
    {
131 8
        foreach ($this->searchPath as $basePath) {
132
            // Try @iconify/json path (full set)
133 8
            yield Path::join((string) $basePath, 'json', $name.'.json');
134
135
            // Try @iconify-json path (cherry picking)
136 4
            yield Path::join((string) $basePath, $name, 'icons.json');
137
        }
138
    }
139
140
    /** @psalm-suppress MissingClosureParamType */
141 18
    protected static function configureOptions(OptionsResolver $resolver = null): OptionsResolver
142
    {
143 18
        $resolver = $resolver ?? new OptionsResolver();
144
145 18
        $resolver->define('cache_dir')
146 18
            ->default(null)
147 18
            ->allowedTypes('null', 'string', \SplFileInfo::class)
148 18
            ->normalize(function (Options $options, $value): string {
149 18
                return is_string($value) ? $value : ($value ?? '');
150 18
            })
151 18
            ->info('Where cache files will be stored');
152
153 18
        return $resolver;
154
    }
155
}
156