3030use Roave \BetterReflection \Reflector \Exception \IdentifierNotFound ;
3131use Roave \BetterReflection \Reflector \Reflector ;
3232use Roave \BetterReflection \SourceLocator \Type \AggregateSourceLocator ;
33+ use Roave \BetterReflection \SourceLocator \Type \Composer \Factory \Exception \MissingComposerJson ;
34+ use Roave \BetterReflection \SourceLocator \Type \Composer \Factory \Exception \MissingInstalledJson ;
35+ use Roave \BetterReflection \SourceLocator \Type \Composer \Factory \MakeLocatorForComposerJsonAndInstalledJson ;
3336use Roave \BetterReflection \SourceLocator \Type \DirectoriesSourceLocator ;
37+ use Roave \BetterReflection \SourceLocator \Type \MemoizingSourceLocator ;
3438use Roave \BetterReflection \SourceLocator \Type \PhpInternalSourceLocator ;
39+ use Roave \BetterReflection \SourceLocator \Type \SingleFileSourceLocator ;
3540
3641/**
3742 * Builds an array of API-surface symbol records for a set of files.
@@ -47,9 +52,11 @@ final class Snapshotter
4752 private Parser $ parser ;
4853
4954 /**
50- * @param list<string> $sourceRoots Directories used to resolve parent classes / interfaces.
55+ * @param list<string> $sourceRoots Directories used to resolve parent classes / interfaces.
56+ * @param string|null $composerProjectPath Optional project root with composer.json + vendor/composer/installed.json. When set, parent / interface
57+ * lookups go through composer's PSR-4 mappings (O(1) per FQCN) instead of recursive directory scans.
5158 */
52- public function __construct (private array $ sourceRoots )
59+ public function __construct (private array $ sourceRoots, private ? string $ composerProjectPath = null )
5360 {
5461 $ this ->prettyPrinter = new PrettyPrinter ();
5562 $ this ->parser = (new ParserFactory ())->createForHostVersion ();
@@ -73,7 +80,7 @@ public function snapshot(array $files): array
7380 return [];
7481 }
7582
76- $ reflector = $ this ->buildReflector ();
83+ $ reflector = $ this ->buildReflector (array_keys ( $ targetFiles ) );
7784 $ records = [];
7885
7986 // Enumerate target classes by parsing each target file directly with
@@ -150,21 +157,50 @@ private function extractClassFqcns(string $absPath): array
150157 return $ fqcns ;
151158 }
152159
153- private function buildReflector (): Reflector
160+ /**
161+ * @param list<string> $targetAbsPaths Absolute paths of files we'll be snapshotting (added at the front of the
162+ * aggregate so target FQCN lookups don't fall through to vendor / src walks).
163+ */
164+ private function buildReflector (array $ targetAbsPaths ): Reflector
154165 {
155166 $ br = new BetterReflection ();
156167 $ astLocator = $ br ->astLocator ();
157168 $ stubber = $ br ->sourceStubber ();
158169
159170 $ locators = [];
171+
172+ // Target files first: lookups for classes declared in changed files
173+ // resolve in O(1) without falling through to the slower DirectoriesSourceLocator.
174+ foreach ($ targetAbsPaths as $ abs ) {
175+ if (is_file ($ abs )) {
176+ $ locators [] = new SingleFileSourceLocator ($ abs , $ astLocator );
177+ }
178+ }
179+
180+ // Composer-aware locator: when a project with composer.json + installed.json
181+ // is provided, all parent / interface lookups under PSR-4 / PSR-0 namespaces
182+ // resolve in O(1) via prefix mapping. Without this, parent resolution into
183+ // vendor walks every file (~thousands of file reads + AST parses per FQCN
184+ // miss) — the dominant cost on real-world projects.
185+ if ($ this ->composerProjectPath !== null && is_dir ($ this ->composerProjectPath )) {
186+ try {
187+ $ locators [] = (new MakeLocatorForComposerJsonAndInstalledJson ())($ this ->composerProjectPath , $ astLocator );
188+ } catch (MissingComposerJson | MissingInstalledJson ) {
189+ // Fall through to source-roots only.
190+ }
191+ }
192+
160193 foreach ($ this ->sourceRoots as $ root ) {
161194 if (is_dir ($ root )) {
162195 $ locators [] = new DirectoriesSourceLocator ([$ root ], $ astLocator );
163196 }
164197 }
165198 $ locators [] = new PhpInternalSourceLocator ($ astLocator , $ stubber );
166199
167- return new DefaultReflector (new AggregateSourceLocator ($ locators ));
200+ // Memoize by FQCN: any parent / interface looked up more than once
201+ // (which happens for every method / constant / property check on
202+ // a class) reuses the cached result instead of re-walking locators.
203+ return new DefaultReflector (new MemoizingSourceLocator (new AggregateSourceLocator ($ locators )));
168204 }
169205
170206 /**
0 commit comments