Lines
92.59%
50 / 54
Methods
66.66%
6 / 9
Classes
0.00%
0 / 1
| 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 | ||
| 35 | final 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 | } |