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
39final 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}