Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.59% covered (success)
92.59%
50 / 54
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
PluginDiscovery
92.59% covered (success)
92.59%
50 / 54
66.67% covered (warning)
66.67%
6 / 9
30.37
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
 getClassMap
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 scan
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 computeSignature
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 readManifest
84.62% covered (success)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
9.29
 writeManifest
87.50% covered (success)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 getNamespaceFromFile
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 normalizedPluginDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 modificationTime
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
1<?php
2
3/**
4 * Discovers plugin classes, optionally backed by a manifest cache.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-07-14
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Plugin;
21
22/**
23 * Class PluginDiscovery
24 *
25 * Maps plugin files to their fully qualified class names. Without a cache
26 * file every call scans the plugin directory and parses each plugin file for
27 * its namespace. With a cache file, the resulting class map is stored as a
28 * PHP manifest and reused as long as the directory signature (plugin root and
29 * per-plugin directory mtimes) is unchanged — so installing or removing a
30 * plugin invalidates the manifest automatically, without a manual cache
31 * clear. Editing the namespace inside an existing plugin file does not bump
32 * a directory mtime; that is a development workflow, where callers should
33 * pass no cache file.
34 */
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}