Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<int, callable(string): void> $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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php declare(strict_types = 1);

namespace PHPStan\Reflection\BetterReflection\SourceLocator;

use PhpParser\PrettyPrinter\Standard;
use PHPStan\BetterReflection\Identifier\Identifier;
use PHPStan\BetterReflection\Identifier\IdentifierType;
use PHPStan\BetterReflection\Reflector\DefaultReflector;
use PHPStan\BetterReflection\SourceLocator\Ast\Locator;
use PHPStan\BetterReflection\SourceLocator\SourceStubber\ReflectionSourceStubber;
use PHPStan\Testing\PHPStanTestCase;
use TestSingleFileSourceLocator\AFoo;
use function class_alias;
use function class_exists;
use function function_exists;

class AutoloadFunctionsSourceLocatorTest extends PHPStanTestCase
{

/**
* A class alias created by a bootstrap-registered autoloader must be located even when a
* *function* of the same name exists - classes and functions live in separate symbol
* spaces, and Laravel's facade aliases (Cache, File, Str, ...) collide with global
* helpers like cache(), file() and str().
*
* @see https://github.com/phpstan/phpstan/issues/15102
*/
public function testLocatesAliasWhoseNameCollidesWithAFunction(): void
{
require_once __DIR__ . '/data/a.php';
$this->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,
);
}

}
Loading