|
2 | 2 |
|
3 | 3 | namespace PHPStan\Reflection\BetterReflection\SourceLocator; |
4 | 4 |
|
| 5 | +use Composer\Autoload\ClassLoader; |
5 | 6 | use Override; |
6 | 7 | use ParseError; |
7 | 8 | use PhpParser\Node\Arg; |
|
31 | 32 | use function defined; |
32 | 33 | use function function_exists; |
33 | 34 | use function interface_exists; |
| 35 | +use function is_array; |
34 | 36 | use function is_file; |
35 | 37 | use function is_string; |
36 | 38 | use function opcache_invalidate; |
| 39 | +use function realpath; |
37 | 40 | use function restore_error_handler; |
38 | 41 | use function set_error_handler; |
39 | 42 | use function spl_autoload_functions; |
| 43 | +use function str_contains; |
40 | 44 | use function strtolower; |
41 | 45 | use function trait_exists; |
42 | 46 | use const PHP_VERSION_ID; |
@@ -333,58 +337,107 @@ private function locateClassByName(string $className): ?array |
333 | 337 | return null; |
334 | 338 | } |
335 | 339 |
|
| 340 | + $autoloadFunctions = spl_autoload_functions(); |
| 341 | + if ($autoloadFunctions === false) { |
| 342 | + return null; |
| 343 | + } |
| 344 | + |
336 | 345 | $this->silenceErrors(); |
337 | 346 |
|
338 | 347 | try { |
339 | | - $result = FileReadTrapStreamWrapper::withStreamWrapperOverride( |
340 | | - static function () use ($className): ?array { |
341 | | - $functions = spl_autoload_functions(); |
342 | | - if ($functions === false) { |
343 | | - return null; |
| 348 | + return self::locateThroughAutoloaders($autoloadFunctions, $className); |
| 349 | + } finally { |
| 350 | + restore_error_handler(); |
| 351 | + } |
| 352 | + } |
| 353 | + |
| 354 | + /** |
| 355 | + * Runs the registered autoloaders, in order, to find which file declares a |
| 356 | + * class - asking Composer's where it would look instead of letting it get |
| 357 | + * there by including the file. |
| 358 | + * |
| 359 | + * ClassLoader::findFile() answers with the same path loadClass() would |
| 360 | + * include, without running anything. Nothing is compiled, so the file cannot |
| 361 | + * execute a second time - which it otherwise does whenever OPcache already |
| 362 | + * holds the script: the include is then served from the cache without the |
| 363 | + * trap being asked for the contents at all, and a file declaring a function |
| 364 | + * fatals with "Cannot redeclare". That is the function-per-file layout of |
| 365 | + * php-standard-library and azjezz/psl, whose files-autoload bootstrap has |
| 366 | + * already loaded every path their PSR-4 prefix also resolves to. |
| 367 | + * |
| 368 | + * Every other autoloader still runs inside the trap, one at a time so that |
| 369 | + * registration order is preserved either way: an autoloader ahead of |
| 370 | + * Composer's may well claim a name Composer would resolve elsewhere. |
| 371 | + * |
| 372 | + * So does a ClassLoader whose answer lies behind a stream wrapper. The phar's |
| 373 | + * own autoloader is one - Box leaves Composer's ClassLoader unprefixed, and it |
| 374 | + * maps PHPStan\ to phar://.../src - and realpath() cannot resolve such a path, |
| 375 | + * while the trap has always recorded them. The OPcache hazard above is about |
| 376 | + * files on disk. |
| 377 | + * |
| 378 | + * @param list<callable(string): void> $autoloadFunctions |
| 379 | + * @return array{string[], string, int|null}|null |
| 380 | + */ |
| 381 | + private static function locateThroughAutoloaders(array $autoloadFunctions, string $className): ?array |
| 382 | + { |
| 383 | + foreach ($autoloadFunctions as $autoloadFunction) { |
| 384 | + if (is_array($autoloadFunction) && $autoloadFunction[0] instanceof ClassLoader) { |
| 385 | + $file = $autoloadFunction[0]->findFile($className); |
| 386 | + if ($file === false) { |
| 387 | + continue; |
| 388 | + } |
| 389 | + |
| 390 | + if (!str_contains($file, '://')) { |
| 391 | + // findFile() concatenates the mapped prefix with the rest of |
| 392 | + // the name, so its answer can carry ../ segments and mixed |
| 393 | + // separators. PHP resolves an include path before the trap |
| 394 | + // ever sees it, and locateIdentifier() matches what lands here |
| 395 | + // against ReflectionClass::getFileName(), which is resolved too. |
| 396 | + $resolvedFile = realpath($file); |
| 397 | + |
| 398 | + // a class map can outlive the file it points at, and |
| 399 | + // loadClass() would move on to the next autoloader just the same |
| 400 | + if ($resolvedFile === false || !is_file($resolvedFile)) { |
| 401 | + continue; |
344 | 402 | } |
345 | 403 |
|
346 | | - foreach ($functions as $preExistingAutoloader) { |
347 | | - try { |
348 | | - $preExistingAutoloader($className); |
349 | | - } catch (ParseError) { |
350 | | - // the trap served a parse error instead of the empty |
351 | | - // script, see FileReadTrapStreamWrapper::stream_read(); |
352 | | - // the file was recorded before the include compiled it |
353 | | - } |
| 404 | + return [[$resolvedFile], $className, null]; |
| 405 | + } |
| 406 | + } |
354 | 407 |
|
355 | | - /** |
356 | | - * This static variable is populated by the side-effect of the stream wrapper |
357 | | - * trying to read the file path when `include()` is used by an autoloader. |
358 | | - * |
359 | | - * This will not be `null` when the autoloader tried to read a file. |
360 | | - */ |
361 | | - if (FileReadTrapStreamWrapper::$autoloadLocatedFiles !== []) { |
362 | | - return [FileReadTrapStreamWrapper::$autoloadLocatedFiles, $className, null]; |
363 | | - } |
| 408 | + $locatedFiles = FileReadTrapStreamWrapper::withStreamWrapperOverride( |
| 409 | + static function () use ($autoloadFunction, $className): array { |
| 410 | + try { |
| 411 | + $autoloadFunction($className); |
| 412 | + } catch (ParseError) { |
| 413 | + // the trap served a parse error instead of the empty |
| 414 | + // script, see FileReadTrapStreamWrapper::stream_read(); |
| 415 | + // the file was recorded before the include compiled it |
364 | 416 | } |
365 | 417 |
|
366 | | - return null; |
| 418 | + // populated by the side effect of the stream wrapper being |
| 419 | + // asked for the file an include() reached for |
| 420 | + return FileReadTrapStreamWrapper::$autoloadLocatedFiles; |
367 | 421 | }, |
368 | 422 | ); |
369 | | - if ($result === null) { |
370 | | - return null; |
371 | | - } |
372 | 423 |
|
373 | | - if (!function_exists('opcache_invalidate')) { |
374 | | - return $result; |
| 424 | + if ($locatedFiles === []) { |
| 425 | + continue; |
375 | 426 | } |
376 | 427 |
|
377 | 428 | // the trap's empty script got compiled - and cached, with OPcache |
378 | 429 | // active. Where this call cannot reach the entry, the trap served a |
379 | 430 | // parse error instead, see FileReadTrapStreamWrapper::stream_read() |
380 | | - foreach ($result[0] as $file) { |
381 | | - opcache_invalidate($file, true); |
| 431 | + if (function_exists('opcache_invalidate')) { |
| 432 | + foreach ($locatedFiles as $locatedFile) { |
| 433 | + opcache_invalidate($locatedFile, true); |
| 434 | + } |
382 | 435 | } |
383 | 436 |
|
384 | | - return $result; |
385 | | - } finally { |
386 | | - restore_error_handler(); |
| 437 | + return [$locatedFiles, $className, null]; |
387 | 438 | } |
| 439 | + |
| 440 | + return null; |
388 | 441 | } |
389 | 442 |
|
390 | 443 | private function silenceErrors(): void |
|
0 commit comments