diff --git a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php index 8ef7d77eabb..e3b4f3bf8b1 100644 --- a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php @@ -10,9 +10,13 @@ use PHPStan\BetterReflection\SourceLocator\Type\SourceLocator; use function class_exists; use function function_exists; +use function get_included_files; +use function in_array; use function interface_exists; use function PHPStan\autoloadFunctions; use function PHPStan\autoloadFunctionsPrependedToComposer; +use function restore_error_handler; +use function set_error_handler; use function trait_exists; final class AutoloadFunctionsSourceLocator implements SourceLocator @@ -43,29 +47,31 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier): return null; } - // If the name is already a defined function, this locator must not run the bootstrap - // autoloaders for it: a catch-all autoloader (e.g. PHP_CodeSniffer's, which falls back to - // Composer's findFile()) would resolve the name to the function's own file and plain-include - // it a second time - it was loaded once already, e.g. by a package that ships one function - // per PSR-4 path and requires it from its bootstrap - fatally redeclaring the function. - // Returning null only declines this locator; a class and a function may share a name in PHP, - // and a class that genuinely exists under this name in another file is still located by the - // later source locators in the chain. See https://github.com/phpstan/phpstan/issues/14988 - if (function_exists($className)) { - return null; - } - $autoloadFunctions = $this->prependedToComposer ? autoloadFunctionsPrependedToComposer() : autoloadFunctions(); + + if ($autoloadFunctions === []) { + return null; + } + + if (function_exists($className)) { + if ($this->wouldReIncludeALoadedFile($autoloadFunctions, $className)) { + return null; + } + + // The trap intercepts file reads, not execution, so the probe ran the autoloaders for + // real. One that defines the class without reading a file - class_alias(), eval() - + // has already done its work, and calling it again would redeclare what it defined. + if (class_exists($className, false) || interface_exists($className, false) || trait_exists($className, false)) { + return $this->locateWithoutAutoloading($reflector, $identifier); + } + } + foreach ($autoloadFunctions as $autoloadFunction) { $autoloadFunction($className); - $reflection = $this->autoloadSourceLocator->locateIdentifier($reflector, $identifier); - if ($reflection !== null) { - return $reflection; - } - $reflection = $this->reflectionClassSourceLocator->locateIdentifier($reflector, $identifier); + $reflection = $this->locateWithoutAutoloading($reflector, $identifier); if ($reflection !== null) { return $reflection; } @@ -74,6 +80,76 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier): return null; } + private function locateWithoutAutoloading(Reflector $reflector, Identifier $identifier): ?Reflection + { + $reflection = $this->autoloadSourceLocator->locateIdentifier($reflector, $identifier); + if ($reflection !== null) { + return $reflection; + } + + return $this->reflectionClassSourceLocator->locateIdentifier($reflector, $identifier); + } + + /** + * Whether running these autoloaders for $className would include a file that is loaded already. + * + * A function of this name exists, so an autoloader that maps names to paths - a catch-all one + * like PHP_CodeSniffer's, falling back to Composer's findFile() - can resolve this *class* name + * to the *function's* own file. Including that file a second time fatally redeclares the + * function, which is what https://github.com/phpstan/phpstan/issues/14988 reported. + * + * Probing under the file-read trap answers which file the autoloaders would read without + * executing it, so only that case is declined. Declining on the name alone would also block + * class names that merely coincide with a function - classes and functions live in separate + * symbol spaces, and Laravel's facade aliases (Cache, File, Str, ...) collide with the global + * helpers cache(), file() and str(). See https://github.com/phpstan/phpstan/issues/15102 + * + * @param array $autoloadFunctions + */ + private function wouldReIncludeALoadedFile(array $autoloadFunctions, string $className): bool + { + set_error_handler(static fn (): bool => true); + + try { + $locatedFiles = FileReadTrapStreamWrapper::withStreamWrapperOverride( + static function () use ($autoloadFunctions, $className): array { + foreach ($autoloadFunctions as $autoloadFunction) { + $autoloadFunction($className); + + // Stop as soon as the name is defined, the way spl_autoload_call() does: + // a later autoloader must not get the chance to resolve a name that is + // already taken care of. Under the trap a file read cannot define + // anything, so this means the autoloader defined it by itself. + if (class_exists($className, false) || interface_exists($className, false) || trait_exists($className, false)) { + return []; + } + + if (FileReadTrapStreamWrapper::$autoloadLocatedFiles !== []) { + return FileReadTrapStreamWrapper::$autoloadLocatedFiles; + } + } + + return []; + }, + ); + } finally { + restore_error_handler(); + } + + if ($locatedFiles === []) { + return false; + } + + $includedFiles = get_included_files(); + foreach ($locatedFiles as $locatedFile) { + if (in_array($locatedFile, $includedFiles, true)) { + return true; + } + } + + return false; + } + #[Override] public function locateIdentifiersByType(Reflector $reflector, IdentifierType $identifierType): array { diff --git a/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocatorTest.php b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocatorTest.php new file mode 100644 index 00000000000..46aad4155ca --- /dev/null +++ b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocatorTest.php @@ -0,0 +1,125 @@ +assertTrue(function_exists('file'), 'precondition: file() is a built-in function'); + $this->assertFalse(class_exists('File', false), 'precondition: no File class yet'); + + $invocations = 0; + $GLOBALS['__phpstanAutoloadFunctions'] = [ + static function (string $class) use (&$invocations): void { + if ($class !== 'File') { + return; + } + + $invocations++; + class_alias(AFoo::class, 'File'); + }, + ]; + + try { + $locator = $this->createLocator(); + $reflection = $locator->locateIdentifier( + new DefaultReflector($locator), + new Identifier('File', new IdentifierType(IdentifierType::IDENTIFIER_CLASS)), + ); + + // Non-null is the point: before the fix this locator declined outright because a + // function named file() exists. The reflection carries the alias *target*'s name - + // rewriting it to the alias is RewriteClassAliasSourceLocator's job, further up the chain. + $this->assertNotNull($reflection, 'the aliased class should be located'); + $this->assertSame(AFoo::class, $reflection->getName()); + + // An autoloader that defines the class itself must not be called a second time: + // class_alias() would then warn that the name is already in use. + $this->assertSame(1, $invocations, 'the autoloader should run exactly once'); + } finally { + unset($GLOBALS['__phpstanAutoloadFunctions']); + } + } + + /** + * A defining autoloader must win over a later catch-all one that would resolve the same name to + * an already-loaded file: the name is taken care of by the time the catch-all runs, exactly as + * spl_autoload_call() would stop there. + */ + public function testDefiningAutoloaderWinsOverALaterCatchAllOne(): void + { + require_once __DIR__ . '/data/a.php'; + $this->assertTrue(function_exists('hash'), 'precondition: hash() is a built-in function'); + $this->assertFalse(class_exists('Hash', false), 'precondition: no Hash class yet'); + + $GLOBALS['__phpstanAutoloadFunctions'] = [ + static function (string $class): void { + if ($class !== 'Hash') { + return; + } + + class_alias(AFoo::class, 'Hash'); + }, + static function (string $class): void { + if ($class !== 'Hash') { + return; + } + + // A catch-all autoloader mapping names to paths - PHP_CodeSniffer's shape - landing + // on a file that is loaded already. + require __DIR__ . '/data/a.php'; + }, + ]; + + try { + $locator = $this->createLocator(); + $reflection = $locator->locateIdentifier( + new DefaultReflector($locator), + new Identifier('Hash', new IdentifierType(IdentifierType::IDENTIFIER_CLASS)), + ); + + $this->assertNotNull($reflection, 'the aliased class should be located'); + $this->assertSame(AFoo::class, $reflection->getName()); + } finally { + unset($GLOBALS['__phpstanAutoloadFunctions']); + } + } + + private function createLocator(): AutoloadFunctionsSourceLocator + { + $container = self::getContainer(); + + return new AutoloadFunctionsSourceLocator( + new AutoloadSourceLocator($container->getByType(FileNodesFetcher::class), false), + new ReflectionClassSourceLocator( + new Locator($container->getService('phpParserDecorator')), + new ReflectionSourceStubber(new Standard()), + ), + false, + ); + } + +}