Lines
90.09%
91 / 101
Methods
44.44%
4 / 9
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| discover | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| directories | 83.33% | 5 / 6 | 0.00% | 0 / 1 | 3.04 | ||
| psr4Directories | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 5 | ||
| isVendorPath | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 4 | ||
| psr4Map | 85.71% | 12 / 14 | 0.00% | 0 / 1 | 8.19 | ||
| scanDirectory | 90.00% | 9 / 10 | 0.00% | 0 / 1 | 5.03 | ||
| processFile | 84.21% | 16 / 19 | 0.00% | 0 / 1 | 10.39 | ||
| declaredClassLikes | 91.42% | 32 / 35 | 0.00% | 0 / 1 | 20.25 | ||
| collectIfModel | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 3 | ||
| 39 | final class ModelDiscovery | |
| 40 | { | |
| 41 | /** | |
| 42 | * The Composer PSR-4 prefix map, resolved lazily on first use. | |
| 43 | * | |
| 44 | * @var array<string, list<string>>|null prefix => absolute directory paths | |
| 45 | */ | |
| 46 | private array|null $psr4Map = null; | |
| 47 | ||
| 48 | /** | |
| 49 | * The discovered model classes. | |
| 50 | * | |
| 51 | * @var list<class-string<Model>> | |
| 52 | */ | |
| 53 | private array $models = []; | |
| 54 | ||
| 55 | /** | |
| 56 | * Discover model classes. | |
| 57 | * | |
| 58 | * @param list<string>|null $dirs Explicit directories (absolute or | |
| 59 | * root-relative). Null scans the app composer.json's PSR-4 dirs. | |
| 60 | * Explicit dirs are scanned AS GIVEN — including vendor/ paths — | |
| 61 | * so a package that ships models can be synced with | |
| 62 | * `sync --dir=vendor/<package>/Models`. | |
| 63 | * @return list<class-string<Model>> | |
| 64 | */ | |
| 65 | public function discover(?array $dirs = null): array | |
| 66 | { | |
| 67 | $this->models = []; | |
| 68 | ||
| 69 | foreach ($this->directories($dirs) as $dir) { | |
| 70 | $this->scanDirectory($dir); | |
| 71 | } | |
| 72 | ||
| 73 | return array_values(array_unique($this->models)); | |
| 74 | } | |
| 75 | ||
| 76 | /** | |
| 77 | * Resolve the directories to scan. | |
| 78 | * | |
| 79 | * @param list<string>|null $dirs Explicit override | |
| 80 | * @return list<string> Absolute directory paths | |
| 81 | */ | |
| 82 | private function directories(?array $dirs): array | |
| 83 | { | |
| 84 | if ($dirs !== null && $dirs !== []) { | |
| 85 | return array_map( | |
| 86 | fn(string $dir): string => FileSystem::absolutePath(trim($dir)), | |
| 87 | $dirs, | |
| 88 | ); | |
| 89 | } | |
| 90 | ||
| 91 | return $this->psr4Directories(); | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Read the live Composer ClassLoader's PSR-4 directories. | |
| 96 | * | |
| 97 | * Uses the registered loaders rather than parsing composer.json, so the | |
| 98 | * result reflects the actual autoloader state (correct when Lucent is a | |
| 99 | * dependency of a consumer project, and for multi-dir prefixes). | |
| 100 | * | |
| 101 | * Dependency (vendor/) directories are EXCLUDED from the default scan: | |
| 102 | * they never hold the app's models, and a vendor-wide scan would include | |
| 103 | * thousands of dependency files pointlessly (each include is also a | |
| 104 | * side-effect risk). Explicit --dir paths bypass this filter — the | |
| 105 | * deliberate escape hatch for packages that ship models. | |
| 106 | * | |
| 107 | * @return list<string> Absolute directory paths | |
| 108 | */ | |
| 109 | private function psr4Directories(): array | |
| 110 | { | |
| 111 | $dirs = []; | |
| 112 | ||
| 113 | foreach ($this->psr4Map() as $paths) { | |
| 114 | foreach ($paths as $path) { | |
| 115 | if (is_dir($path) && !$this->isVendorPath($path)) { | |
| 116 | $dirs[] = $path; | |
| 117 | } | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | return array_values(array_unique($dirs)); | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * Whether the path lives inside a Composer vendor directory. | |
| 126 | * | |
| 127 | * @param string $path Absolute directory path | |
| 128 | */ | |
| 129 | private function isVendorPath(string $path): bool | |
| 130 | { | |
| 131 | // The vendor dir is derivable from the registered loaders' keys | |
| 132 | // (ClassLoader::getRegisteredLoaders() is keyed by vendor dir). | |
| 133 | foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) { | |
| 134 | $vendorDir = rtrim((string) realpath($vendorDir), '/\\'); | |
| 135 | ||
| 136 | if ($vendorDir !== '' && str_starts_with($path, $vendorDir . DIRECTORY_SEPARATOR)) { | |
| 137 | return true; | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | return false; | |
| 142 | } | |
| 143 | ||
| 144 | /** | |
| 145 | * Resolve the Composer PSR-4 prefix map once — prefix => absolute dirs. | |
| 146 | * | |
| 147 | * Resolved lazily on first use; {@see psr4Directories()} reads the | |
| 148 | * snapshot instead of re-querying the ClassLoader. | |
| 149 | * | |
| 150 | * @return array<string, list<string>> | |
| 151 | */ | |
| 152 | private function psr4Map(): array | |
| 153 | { | |
| 154 | if ($this->psr4Map !== null) { | |
| 155 | return $this->psr4Map; | |
| 156 | } | |
| 157 | ||
| 158 | $map = []; | |
| 159 | ||
| 160 | foreach (ClassLoader::getRegisteredLoaders() as $loader) { | |
| 161 | foreach ($loader->getPrefixesPsr4() as $prefix => $paths) { | |
| 162 | foreach ((array) $paths as $path) { | |
| 163 | if (!is_string($path) || $path === '') { | |
| 164 | continue; | |
| 165 | } | |
| 166 | ||
| 167 | // Composer's generated static autoloader stores path | |
| 168 | // strings with embedded `..` segments (e.g. | |
| 169 | // "vendor/composer/../../src/Lucent"). Consumers of the | |
| 170 | // map compare by string prefix, so the segments must be | |
| 171 | // resolved lexically first — otherwise isVendorPath() | |
| 172 | // matches the "vendor/" inside the literal string and | |
| 173 | // filters out the app's own directories too. | |
| 174 | $absolute = FileSystem::normalizePath( | |
| 175 | FileSystem::absolutePath(rtrim($path, '/\\')), | |
| 176 | ); | |
| 177 | ||
| 178 | if (is_dir($absolute)) { | |
| 179 | $map[$prefix][] = $absolute; | |
| 180 | } | |
| 181 | } | |
| 182 | } | |
| 183 | } | |
| 184 | ||
| 185 | return $this->psr4Map = $map; | |
| 186 | } | |
| 187 | ||
| 188 | /** | |
| 189 | * Recursively scan a directory for PHP files and collect Model subclasses. | |
| 190 | * | |
| 191 | * @param string $dir Absolute directory path | |
| 192 | */ | |
| 193 | private function scanDirectory(string $dir): void | |
| 194 | { | |
| 195 | if (!is_dir($dir)) { | |
| 196 | return; | |
| 197 | } | |
| 198 | ||
| 199 | $iterator = new \RecursiveIteratorIterator( | |
| 200 | new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), | |
| 201 | \RecursiveIteratorIterator::LEAVES_ONLY, | |
| 202 | ); | |
| 203 | ||
| 204 | foreach ($iterator as $fileInfo) { | |
| 205 | /** @var \SplFileInfo $fileInfo */ | |
| 206 | if (!$fileInfo->isFile() || $fileInfo->getExtension() !== 'php') { | |
| 207 | continue; | |
| 208 | } | |
| 209 | ||
| 210 | $this->processFile($fileInfo->getPathname()); | |
| 211 | } | |
| 212 | } | |
| 213 | ||
| 214 | /** | |
| 215 | * Inspect a PHP file and collect its Model subclasses. | |
| 216 | * | |
| 217 | * The declared class-like names are read from tokens first; the file is | |
| 218 | * included only when a declared name is not already loaded, so the | |
| 219 | * subclass check can resolve parents declared in other files. | |
| 220 | * | |
| 221 | * @param string $path Absolute file path | |
| 222 | */ | |
| 223 | private function processFile(string $path): void | |
| 224 | { | |
| 225 | $candidates = $this->declaredClassLikes($path); | |
| 226 | ||
| 227 | // Files that declare nothing are scripts (route files, test-server | |
| 228 | // routers, CLI entrypoints) — they execute side effects when | |
| 229 | // included and must never run during discovery. | |
| 230 | if ($candidates === []) { | |
| 231 | return; | |
| 232 | } | |
| 233 | ||
| 234 | $declared = []; | |
| 235 | $toLoad = []; | |
| 236 | ||
| 237 | foreach ($candidates as $candidate) { | |
| 238 | if (class_exists($candidate, false)) { | |
| 239 | $declared[] = $candidate; | |
| 240 | } else { | |
| 241 | $toLoad[] = $candidate; | |
| 242 | } | |
| 243 | } | |
| 244 | ||
| 245 | foreach ($declared as $candidate) { | |
| 246 | $this->collectIfModel($candidate); | |
| 247 | } | |
| 248 | ||
| 249 | // A name the file declares that is ALREADY declared means another | |
| 250 | // file owns it (a duplicate FQCN) — including this file would fatal | |
| 251 | // with "Cannot redeclare class", which is not catchable. The already | |
| 252 | // declared names were collected above; skip the include entirely. | |
| 253 | if ($toLoad === [] || $declared !== []) { | |
| 254 | return; | |
| 255 | } | |
| 256 | ||
| 257 | try { | |
| 258 | require_once $path; | |
| 259 | } catch (\Throwable) { | |
| 260 | // Include-time failure — a reference that does not resolve (a | |
| 261 | // missing parent/interface) or a parse error. Not discoverable | |
| 262 | // as a model; skip the file. | |
| 263 | return; | |
| 264 | } | |
| 265 | ||
| 266 | foreach ($toLoad as $candidate) { | |
| 267 | if (class_exists($candidate, false)) { | |
| 268 | $this->collectIfModel($candidate); | |
| 269 | } | |
| 270 | } | |
| 271 | } | |
| 272 | ||
| 273 | /** | |
| 274 | * The class-like names (class / interface / trait / enum) a file | |
| 275 | * declares, fully qualified. | |
| 276 | * | |
| 277 | * A single token pass over the source — no include, no autoloading. | |
| 278 | * The `namespace` statement sets the qualifying prefix; the name | |
| 279 | * following each declaration keyword is collected (an anonymous class | |
| 280 | * has no name and collects nothing). `::class` constant references are | |
| 281 | * skipped — they are preceded by a double-colon. Comments, docblocks | |
| 282 | * and heredoc bodies are separate tokens, so prose mentioning "class" | |
| 283 | * never false-positives. | |
| 284 | * | |
| 285 | * @param string $path Absolute file path | |
| 286 | * @return list<class-string> | |
| 287 | */ | |
| 288 | private function declaredClassLikes(string $path): array | |
| 289 | { | |
| 290 | $source = file_get_contents($path); | |
| 291 | ||
| 292 | if ($source === false || $source === '') { | |
| 293 | return []; | |
| 294 | } | |
| 295 | ||
| 296 | $names = []; | |
| 297 | $namespace = ''; | |
| 298 | $pending = null; // 'namespace' | 'classlike' | null | |
| 299 | $previous = null; // id of the previous significant code token | |
| 300 | ||
| 301 | foreach (\token_get_all($source) as $token) { | |
| 302 | if (!is_array($token)) { | |
| 303 | // A single-char token can never start a declared name — | |
| 304 | // e.g. the '(' of an anonymous `new class(...)` — so it | |
| 305 | // cancels any pending declaration. | |
| 306 | $pending = null; | |
| 307 | $previous = $token; | |
| 308 | continue; | |
| 309 | } | |
| 310 | ||
| 311 | [$id, $text] = $token; | |
| 312 | ||
| 313 | if ($id === T_WHITESPACE || $id === T_COMMENT || $id === T_DOC_COMMENT) { | |
| 314 | continue; // never separates a keyword from its name | |
| 315 | } | |
| 316 | ||
| 317 | if ($pending !== null) { | |
| 318 | if ( | |
| 319 | $id === T_STRING || $id === T_NAME_QUALIFIED | |
| 320 | || $id === T_NAME_FULLY_QUALIFIED | |
| 321 | ) { | |
| 322 | if ($pending === 'namespace') { | |
| 323 | $namespace = ltrim($text, '\\'); | |
| 324 | } else { | |
| 325 | $names[] = $namespace === '' | |
| 326 | ? $text | |
| 327 | : $namespace . '\\' . $text; | |
| 328 | } | |
| 329 | ||
| 330 | $pending = null; | |
| 331 | $previous = $id; | |
| 332 | continue; | |
| 333 | } | |
| 334 | ||
| 335 | $pending = null; // not a name — cancel and process normally | |
| 336 | } | |
| 337 | ||
| 338 | if ( | |
| 339 | $id === T_CLASS || $id === T_INTERFACE | |
| 340 | || $id === T_TRAIT || $id === T_ENUM | |
| 341 | ) { | |
| 342 | // "::class" is two tokens: T_DOUBLE_COLON then T_CLASS — | |
| 343 | // a constant reference, not a declaration. | |
| 344 | if ($previous !== T_DOUBLE_COLON) { | |
| 345 | $pending = 'classlike'; | |
| 346 | } | |
| 347 | } elseif ($id === T_NAMESPACE) { | |
| 348 | $pending = 'namespace'; | |
| 349 | } | |
| 350 | ||
| 351 | $previous = $id; | |
| 352 | } | |
| 353 | ||
| 354 | return $names; | |
| 355 | } | |
| 356 | ||
| 357 | /** | |
| 358 | * Collect a class when it is a concrete Model subclass. | |
| 359 | * | |
| 360 | * @param class-string $class | |
| 361 | */ | |
| 362 | private function collectIfModel(string $class): void | |
| 363 | { | |
| 364 | if (is_subclass_of($class, Model::class) && !(new ReflectionClass($class))->isAbstract()) { | |
| 365 | $this->models[] = $class; | |
| 366 | } | |
| 367 | } | |
| 368 | } |