Lines 92.59% 50 / 54
Methods 66.66% 6 / 9
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 getClassMap 100.00% 9 / 9 100.00% 1 / 1 4
 scan 100.00% 7 / 7 100.00% 1 / 1 3
 computeSignature 100.00% 6 / 6 100.00% 1 / 1 3
 readManifest 84.61% 11 / 13 0.00% 0 / 1 9.29
 writeManifest 87.50% 7 / 8 0.00% 0 / 1 3.02
 getNamespaceFromFile 100.00% 5 / 5 100.00% 1 / 1 3
 normalizedPluginDir 100.00% 1 / 1 100.00% 1 / 1 1
 modificationTime 75.00% 3 / 4 0.00% 0 / 1 3.14
35final class PluginDiscovery
36{
37    public function __construct(
38        private readonly string $pluginDir,
39        private readonly ?string $cacheFile = null,
40    ) {
41    }
42
43    /**
44     * @return array<string, string> plugin file path => fully qualified class name
45     */
46    public function getClassMap(): array
47    {
48        if ($this->cacheFile === null) {
49            return $this->scan();
50        }
51
52        $signature = $this->computeSignature();
53        $manifest = $this->readManifest();
54        if ($manifest !== null && $manifest['signature'] === $signature) {
55            return $manifest['classMap'];
56        }
57
58        $classMap = $this->scan();
59        $this->writeManifest($signature, $classMap);
60
61        return $classMap;
62    }
63
64    /**
65     * @return array<string, string>
66     */
67    private function scan(): array
68    {
69        $classMap = [];
70        $pluginFiles = glob($this->normalizedPluginDir() . '/*/*Plugin.php');
71
72        foreach ($pluginFiles === false ? [] : $pluginFiles as $pluginFile) {
73            $className = basename(path: $pluginFile, suffix: '.php');
74            $namespace = $this->getNamespaceFromFile($pluginFile) ?? '';
75            $classMap[$pluginFile] = $namespace . '\\' . $className;
76        }
77
78        return $classMap;
79    }
80
81    /**
82     * Signature over the plugin root and each plugin directory mtime.
83     */
84    private function computeSignature(): string
85    {
86        $pluginDir = $this->normalizedPluginDir();
87        $parts = [$pluginDir => $this->modificationTime($pluginDir)];
88
89        $subDirectories = glob($pluginDir . '/*', GLOB_ONLYDIR);
90        foreach ($subDirectories === false ? [] : $subDirectories as $subDirectory) {
91            $parts[$subDirectory] = $this->modificationTime($subDirectory);
92        }
93
94        return md5(serialize($parts));
95    }
96
97    /**
98     * @return array{signature: string, classMap: array<string, string>}|null
99     */
100    private function readManifest(): ?array
101    {
102        if ($this->cacheFile === null || !is_file($this->cacheFile)) {
103            return null;
104        }
105
106        /** @var mixed $manifest */
107        $manifest = include $this->cacheFile;
108        if (
109            !is_array($manifest)
110            || !is_string($manifest['signature'] ?? null)
111            || !is_array($manifest['classMap'] ?? null)
112        ) {
113            return null;
114        }
115
116        $classMap = [];
117        /** @var mixed $className */
118        foreach ($manifest['classMap'] as $pluginFile => $className) {
119            if (!is_string($pluginFile) || !is_string($className)) {
120                return null;
121            }
122
123            $classMap[$pluginFile] = $className;
124        }
125
126        return ['signature' => $manifest['signature'], 'classMap' => $classMap];
127    }
128
129    /**
130     * @param array<string, string> $classMap
131     */
132    private function writeManifest(string $signature, array $classMap): void
133    {
134        if ($this->cacheFile === null) {
135            return;
136        }
137
138        $cacheDir = dirname($this->cacheFile);
139        if (!is_dir($cacheDir)) {
140            mkdir(directory: $cacheDir, permissions: 0o755, recursive: true);
141        }
142
143        $manifest = ['signature' => $signature, 'classMap' => $classMap];
144        $payload = "<?php\n\nreturn " . var_export($manifest, return: true) . ";\n";
145        file_put_contents($this->cacheFile, $payload, LOCK_EX);
146    }
147
148    private function getNamespaceFromFile(string $file): ?string
149    {
150        $src = file_get_contents($file);
151        $matches = [];
152        if ($src !== false && preg_match('/^namespace\s+(.+?);/m', $src, $matches)) {
153            return $matches[1];
154        }
155
156        return null;
157    }
158
159    private function normalizedPluginDir(): string
160    {
161        return rtrim(string: $this->pluginDir, characters: '/');
162    }
163
164    private function modificationTime(string $directory): int
165    {
166        if (!is_dir($directory)) {
167            return 0;
168        }
169
170        $modificationTime = filemtime($directory);
171
172        return $modificationTime === false ? 0 : $modificationTime;
173    }
174}