Repository: Roave/BetterReflection (php)

Issue:
AutoloadSourceLocator::locateClassByName can crash with "Class MemoizingParser not found"
Steps to reproduce:

1) Instantiate AutoloadSourceLocator with own Parser instance (second constructor argument).
2) Ask it to locate some class reflection.
3) `locateClassByName` gets executed with the stream wrapper magic. `class_exists` leads to executing AutoloadSourceLocator constructor again, but this time, `$betterReflection->phpParser()` gets called.
4) `new MemoizingParser` fails because the class is not known and we're in a special mode with unregistered `file` wrapper so file loading does not work.

Proof of concept fix: https://github.com/ondrejmirtes/BetterReflection/commit/d7e44eeb962165ef489c498319a5ba46e3c51c0b

Pull request:
#573 ensure `AutoloadSourceLocator` does not interfere with autoloading of PHP-Parser nor BetterReflection internals
Fixes #573
Fixes #575

Removed stream wrapper logic from `AutoloadSourceLocator`: using the new `FileReadTrapStreamWrapper` for that
    
This simplifies the `AutoloadSourceLocator` massively:
    
 * the constructor no longer needs to be zero-arguments (although not changed that, for BC compliance)
 * internal state slimmed down
 * magic around the object is "mostly" gone
 * allows using custom AST locators internally


Patch:
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index d504aca7..18859517 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -18,15 +18,6 @@
         <exclude name="SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingTraversableTypeHintSpecification"/>
     </rule>
 
-    <rule ref="Squiz.NamingConventions.ValidVariableName.NotCamelCaps">
-        <!-- Contains stream wrapper -->
-        <exclude-pattern>src/SourceLocator/Type/AutoloadSourceLocator.php</exclude-pattern>
-    </rule>
-    <rule ref="PSR1.Methods.CamelCapsMethodName.NotCamelCaps">
-        <!-- Contains stream wrapper -->
-        <exclude-pattern>src/SourceLocator/Type/AutoloadSourceLocator.php</exclude-pattern>
-    </rule>
-
     <rule ref="SlevomatCodingStandard.TypeHints.ParameterTypeHint">
         <properties>
             <property name="enableObjectTypeHint" value="false"/>
diff --git a/src/SourceLocator/Type/AutoloadSourceLocator.php b/src/SourceLocator/Type/AutoloadSourceLocator.php
index 981a7e3d..ed3dc639 100644
--- a/src/SourceLocator/Type/AutoloadSourceLocator.php
+++ b/src/SourceLocator/Type/AutoloadSourceLocator.php
@@ -19,8 +19,10 @@ use Roave\BetterReflection\Reflection\Exception\InvalidConstantNode;
 use Roave\BetterReflection\SourceLocator\Ast\Locator as AstLocator;
 use Roave\BetterReflection\SourceLocator\Exception\InvalidFileLocation;
 use Roave\BetterReflection\SourceLocator\Located\LocatedSource;
+use Roave\BetterReflection\SourceLocator\Type\AutoloadSourceLocator\FileReadTrapStreamWrapper;
 use Roave\BetterReflection\Util\ConstantNodeChecker;
 use function array_key_exists;
+use function array_reverse;
 use function assert;
 use function class_exists;
 use function defined;
@@ -33,29 +35,18 @@ use function interface_exists;
 use function is_string;
 use function restore_error_handler;
 use function set_error_handler;
-use function stat;
-use function stream_wrapper_register;
-use function stream_wrapper_restore;
-use function stream_wrapper_unregister;
+use function spl_autoload_functions;
 use function trait_exists;
-use const STREAM_URL_STAT_QUIET;
 
 /**
  * Use PHP's built in autoloader to locate a class, without actually loading.
  *
  * There are some prerequisites...
  *   - we expect the autoloader to load classes from a file (i.e. using require/include)
+ *   - your autoloader of choice does not replace stream wrappers
  */
 class AutoloadSourceLocator extends AbstractSourceLocator
 {
-    private const DEFAULT_STREAM_WRAPPER_PROTOCOLS = [
-        'file',
-        'phar',
-    ];
-
-    /** @var AstLocator */
-    private $astLocator;
-
     /** @var Parser */
     private $phpParser;
 
@@ -65,44 +56,20 @@ class AutoloadSourceLocator extends AbstractSourceLocator
     /** @var NodeVisitorAbstract */
     private $constantVisitor;
 
-    /** @var string[] */
-    private $streamWrapperProtocols;
-
-    /**
-     * Note: the constructor has been made a 0-argument constructor because `\stream_wrapper_register`
-     *       is a piece of trash, and doesn't accept instances, just class names.
-     *
-     * @param string[] $streamWrapperProtocols
-     */
-    public function __construct(?AstLocator $astLocator = null, ?Parser $phpParser = null, array $streamWrapperProtocols = self::DEFAULT_STREAM_WRAPPER_PROTOCOLS)
+    public function __construct(?AstLocator $astLocator = null, ?Parser $phpParser = null)
     {
         $betterReflection = new BetterReflection();
 
-        $validLocator = $astLocator ?? self::$currentAstLocator ?? $betterReflection->astLocator();
+        parent::__construct($astLocator ?? $betterReflection->astLocator());
 
-        parent::__construct($validLocator);
-
-        $this->astLocator             = $validLocator;
-        $this->phpParser              = $phpParser ?? $betterReflection->phpParser();
-        $this->streamWrapperProtocols = $streamWrapperProtocols;
-        $this->constantVisitor        = $this->createConstantVisitor();
+        $this->phpParser       = $phpParser ?? $betterReflection->phpParser();
+        $this->constantVisitor = $this->createConstantVisitor();
 
         $this->nodeTraverser = new NodeTraverser();
         $this->nodeTraverser->addVisitor(new NameResolver());
         $this->nodeTraverser->addVisitor($this->constantVisitor);
     }
 
-    /**
-     * Primarily used by the non-loading-autoloader magic trickery to determine
-     * the filename used during autoloading.
-     *
-     * @var string|null
-     */
-    private static $autoloadLocatedFile;
-
-    /** @var AstLocator */
-    private static $currentAstLocator;
-
     /**
      * {@inheritDoc}
      *
@@ -158,6 +125,12 @@ class AutoloadSourceLocator extends AbstractSourceLocator
      * that it cannot find the file, so we squelch the errors by overriding the
      * error handler temporarily.
      *
+     * Note: the following code is designed so that the first hit on an actual
+     *       **file** leads to a path being resolved. No actual autoloading nor
+     *       file reading should happen, and most certainly no other classes
+     *       should exist after execution. The only filesystem access is to
+     *       check whether the file exists.
+     *
      * @throws ReflectionException
      */
     private function locateClassByName(string $className) : ?string
@@ -172,27 +145,31 @@ class AutoloadSourceLocator extends AbstractSourceLocator
             return $filename;
         }
 
-        self::$autoloadLocatedFile = null;
-        self::$currentAstLocator   = $this->astLocator; // passing the locator on to the implicitly instantiated `self`
-
         $this->silenceErrors();
 
-        foreach ($this->streamWrapperProtocols as $protocol) {
-            stream_wrapper_unregister($protocol);
-            stream_wrapper_register($protocol, self::class);
-        }
-
         try {
-            class_exists($className);
-        } finally {
-            foreach ($this->streamWrapperProtocols as $protocol) {
-                stream_wrapper_restore($protocol);
-            }
+            return FileReadTrapStreamWrapper::withStreamWrapperOverride(
+                static function () use ($className) : ?string {
+                    foreach (spl_autoload_functions() as $preExistingAutoloader) {
+                        $preExistingAutoloader($className);
+
+                        /**
+                         * This static variable is populated by the side-effect of the stream wrapper
+                         * trying to read the file path when `include()` is used by an autoloader.
+                         *
+                         * This will not be `null` when the autoloader tried to read a file.
+                         */
+                        if (FileReadTrapStreamWrapper::$autoloadLocatedFile !== null) {
+                            return FileReadTrapStreamWrapper::$autoloadLocatedFile;
+                        }
+                    }
 
+                    return null;
+                }
+            );
+        } finally {
             restore_error_handler();
         }
-
-        return self::$autoloadLocatedFile;
     }
 
     private function silenceErrors() : void
@@ -216,8 +193,7 @@ class AutoloadSourceLocator extends AbstractSourceLocator
             return null;
         }
 
-        $reflection         = new ReflectionFunction($functionName);
-        $reflectionFileName = $reflection->getFileName();
+        $reflectionFileName = (new ReflectionFunction($functionName))->getFileName();
 
         if (! is_string($reflectionFileName)) {
             return null;
@@ -249,7 +225,10 @@ class AutoloadSourceLocator extends AbstractSourceLocator
 
         $constantFileName = null;
 
-        foreach (get_included_files() as $includedFileName) {
+        // Note: looking at files in reverse order, since newer files are more likely to have
+        //       defined a constant that is being looked up. Earlier files are possibly related
+        //       to libraries/frameworks that we rely upon.
+        foreach (array_reverse(get_included_files()) as $includedFileName) {
             $ast = $this->phpParser->parse(file_get_contents($includedFileName));
 
             $this->nodeTraverser->traverse($ast);
@@ -264,64 +243,6 @@ class AutoloadSourceLocator extends AbstractSourceLocator
         return $constantFileName;
     }
 
-    /**
-     * Our wrapper simply records which file we tried to load and returns
-     * boolean false indicating failure.
-     *
-     * @see https://php.net/manual/en/class.streamwrapper.php
-     * @see https://php.net/manual/en/streamwrapper.stream-open.php
-     *
-     * @param string $path
-     * @param string $mode
-     * @param int    $options
-     * @param string $opened_path
-     *
-     * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint
-     */
-    public function stream_open($path, $mode, $options, &$opened_path) : bool
-    {
-        self::$autoloadLocatedFile = $path;
-
-        return false;
-    }
-
-    /**
-     * url_stat is triggered by calls like "file_exists". The call to "file_exists" must not be overloaded.
-     * This function restores the original "file" stream, issues a call to "stat" to get the real results,
-     * and then re-registers the AutoloadSourceLocator stream wrapper.
-     *
-     * @see https://php.net/manual/en/class.streamwrapper.php
-     * @see https://php.net/manual/en/streamwrapper.url-stat.php
-     *
-     * @param string $path
-     * @param int    $flags
-     *
-     * @return mixed[]|bool
-     *
-     * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint
-     */
-    public function url_stat($path, $flags)
-    {
-        foreach ($this->streamWrapperProtocols as $protocol) {
-            stream_wrapper_restore($protocol);
-        }
-
-        if ($flags & STREAM_URL_STAT_QUIET) {
-            restore_error_handler();
-            $result = @stat($path);
-            $this->silenceErrors();
-        } else {
-            $result = stat($path);
-        }
-
-        foreach ($this->streamWrapperProtocols as $protocol) {
-            stream_wrapper_unregister($protocol);
-            stream_wrapper_register($protocol, self::class);
-        }
-
-        return $result;
-    }
-
     private function createConstantVisitor() : NodeVisitorAbstract
     {
         return new class() extends NodeVisitorAbstract
@@ -336,7 +257,6 @@ class AutoloadSourceLocator extends AbstractSourceLocator
             {
                 if ($node instanceof Node\Stmt\Const_) {
                     foreach ($node->consts as $constNode) {
-                        /** @psalm-suppress UndefinedPropertyFetch */
                         if ($constNode->namespacedName->toString() === $this->constantName) {
                             $this->node = $node;
 
diff --git a/src/SourceLocator/Type/AutoloadSourceLocator/FileReadTrapStreamWrapper.php b/src/SourceLocator/Type/AutoloadSourceLocator/FileReadTrapStreamWrapper.php
new file mode 100644
index 00000000..79b41aa5
--- /dev/null
+++ b/src/SourceLocator/Type/AutoloadSourceLocator/FileReadTrapStreamWrapper.php
@@ -0,0 +1,140 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Roave\BetterReflection\SourceLocator\Type\AutoloadSourceLocator;
+
+use LogicException;
+use function stat;
+use function stream_wrapper_register;
+use function stream_wrapper_restore;
+use function stream_wrapper_unregister;
+use const STREAM_URL_STAT_QUIET;
+
+/**
+ * This class will operate as a stream wrapper, intercepting any access to a file while
+ * in operation.
+ *
+ * @internal DO NOT USE: this is an implementation detail of
+ *           the {@see \Roave\BetterReflection\SourceLocator\Type\AutoloadSourceLocator}
+ *
+ * phpcs:disable SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint
+ * phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps
+ * phpcs:disable Squiz.NamingConventions.ValidVariableName.NotCamelCaps
+ */
+final class FileReadTrapStreamWrapper
+{
+    private const DEFAULT_STREAM_WRAPPER_PROTOCOLS = [
+        'file',
+        'phar',
+    ];
+
+    /** @var string[]|null */
+    private static $registeredStreamWrapperProtocols;
+
+    /**
+     * Read this property to determine the last file on which reads were attempted
+     *
+     * @var string|null
+     * @psalm-readonly
+     * @psalm-allow-private-mutation
+     */
+    public static $autoloadLocatedFile;
+
+    /**
+     * @param string[] $streamWrapperProtocols
+     *
+     * @return mixed
+     *
+     * @psalm-template ExecutedMethodReturnType of mixed
+     * @psalm-param callable() : ExecutedMethodReturnType $executeMeWithinStreamWrapperOverride
+     * @psalm-return ExecutedMethodReturnType
+     */
+    public static function withStreamWrapperOverride(
+        callable $executeMeWithinStreamWrapperOverride,
+        array $streamWrapperProtocols = self::DEFAULT_STREAM_WRAPPER_PROTOCOLS
+    ) {
+        self::$registeredStreamWrapperProtocols = $streamWrapperProtocols;
+        self::$autoloadLocatedFile              = null;
+
+        try {
+            foreach ($streamWrapperProtocols as $protocol) {
+                stream_wrapper_unregister($protocol);
+                stream_wrapper_register($protocol, self::class);
+            }
+
+            $result = $executeMeWithinStreamWrapperOverride();
+        } finally {
+            foreach ($streamWrapperProtocols as $protocol) {
+                stream_wrapper_restore($protocol);
+            }
+        }
+
+        self::$registeredStreamWrapperProtocols = null;
+        self::$autoloadLocatedFile              = null;
+
+        return $result;
+    }
+
+    /**
+     * Our wrapper simply records which file we tried to load and returns
+     * boolean false indicating failure.
+     *
+     * @internal do not call this method directly! This is stream wrapper
+     *           voodoo logic that you **DO NOT** want to touch!
+     *
+     * @see https://php.net/manual/en/class.streamwrapper.php
+     * @see https://php.net/manual/en/streamwrapper.stream-open.php
+     *
+     * @param string $path
+     * @param string $mode
+     * @param int    $options
+     * @param string $opened_path
+     */
+    public function stream_open($path, $mode, $options, &$opened_path) : bool
+    {
+        self::$autoloadLocatedFile = $path;
+
+        return false;
+    }
+
+    /**
+     * url_stat is triggered by calls like "file_exists". The call to "file_exists" must not be overloaded.
+     * This function restores the original "file" stream, issues a call to "stat" to get the real results,
+     * and then re-registers the AutoloadSourceLocator stream wrapper.
+     *
+     * @internal do not call this method directly! This is stream wrapper
+     *           voodoo logic that you **DO NOT** want to touch!
+     *
+     * @see https://php.net/manual/en/class.streamwrapper.php
+     * @see https://php.net/manual/en/streamwrapper.url-stat.php
+     *
+     * @param string $path
+     * @param int    $flags
+     *
+     * @return mixed[]|bool
+     */
+    public function url_stat($path, $flags)
+    {
+        if (self::$registeredStreamWrapperProtocols === null) {
+            throw new LogicException(self::class . ' not registered: cannot operate. Do not call this method directly.');
+        }
+
+        foreach (self::$registeredStreamWrapperProtocols as $protocol) {
+            stream_wrapper_restore($protocol);
+        }
+
+        if ($flags & STREAM_URL_STAT_QUIET) {
+            $result = @stat($path);
+        } else {
+            $result = stat($path);
+        }
+
+        foreach (self::$registeredStreamWrapperProtocols as $protocol) {
+            stream_wrapper_unregister($protocol);
+            stream_wrapper_register($protocol, self::class);
+        }
+
+        return $result;
+    }
+}


Test patch:
diff --git a/test/unit/SourceLocator/Type/AutoloadSourceLocator/FileReadTrapStreamWrapperTest.php b/test/unit/SourceLocator/Type/AutoloadSourceLocator/FileReadTrapStreamWrapperTest.php
new file mode 100644
index 00000000..7300d78a
--- /dev/null
+++ b/test/unit/SourceLocator/Type/AutoloadSourceLocator/FileReadTrapStreamWrapperTest.php
@@ -0,0 +1,199 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Roave\BetterReflectionTest\SourceLocator\Type\AutoloadSourceLocator;
+
+use Exception;
+use PHPUnit\Framework\Error\Warning;
+use PHPUnit\Framework\TestCase;
+use Roave\BetterReflection\SourceLocator\Type\AutoloadSourceLocator\FileReadTrapStreamWrapper;
+use Throwable;
+use UnexpectedValueException;
+use function class_exists;
+use function file_exists;
+use function file_get_contents;
+use function uniqid;
+
+/**
+ * @covers \Roave\BetterReflection\SourceLocator\Type\AutoloadSourceLocator\FileReadTrapStreamWrapper
+ *
+ * Note: stream wrappers interfere **HEAVILY** with autoloaders, therefore we need to operate with raw
+ *       assertions rather than with PHPUnit's assertion utilities, which lead to autoloading in case
+ *       of lazy assertions or assertion failures.
+ */
+class FileReadTrapStreamWrapperTest extends TestCase
+{
+    public function testWillFindFileLocationOfExistingFileWhenFileIsRead() : void
+    {
+        self::assertNull(FileReadTrapStreamWrapper::$autoloadLocatedFile);
+
+        self::assertSame(
+            'value produced by the function',
+            FileReadTrapStreamWrapper::withStreamWrapperOverride(
+                static function () : string {
+                    if (FileReadTrapStreamWrapper::$autoloadLocatedFile !== null) {
+                        throw new UnexpectedValueException('FileReadTrapStreamWrapper::$autoloadLocatedFile should be null when being first used');
+                    }
+
+                    if (! file_exists(__FILE__)) {
+                        throw new UnexpectedValueException('file_exists() should operate as usual');
+                    }
+
+                    if (FileReadTrapStreamWrapper::$autoloadLocatedFile !== null) {
+                        throw new UnexpectedValueException('FileReadTrapStreamWrapper::$autoloadLocatedFile should not be populated when file existence is checked');
+                    }
+
+                    if (@file_get_contents(__FILE__)) {
+                        throw new UnexpectedValueException('file_get_contents() should fail: file should not be readable when this stream wrapper is active');
+                    }
+
+                    if (__FILE__ !== FileReadTrapStreamWrapper::$autoloadLocatedFile) {
+                        throw new UnexpectedValueException('FileReadTrapStreamWrapper::$autoloadLocatedFile should have been populated by the failed file access');
+                    }
+
+                    return 'value produced by the function';
+                },
+                ['file']
+            )
+        );
+
+        self::assertNull(FileReadTrapStreamWrapper::$autoloadLocatedFile);
+        self::assertNotEmpty(file_get_contents(__FILE__), 'Stream wrapper was removed, file reads work again');
+    }
+
+    public function testWillReportAttemptedAccessesToNonExistingFiles() : void
+    {
+        self::assertNull(FileReadTrapStreamWrapper::$autoloadLocatedFile);
+
+        $nonExistingFilePath = __DIR__ . '/' . uniqid('non-existing-file', true);
+
+        self::assertSame(
+            'the value produced by the function',
+            FileReadTrapStreamWrapper::withStreamWrapperOverride(
+                static function () use ($nonExistingFilePath) : string {
+                    if (FileReadTrapStreamWrapper::$autoloadLocatedFile !== null) {
+                        throw new UnexpectedValueException('FileReadTrapStreamWrapper::$autoloadLocatedFile should be null when being first used');
+                    }
+
+                    if (file_exists($nonExistingFilePath)) {
+                        throw new UnexpectedValueException('file_exists() should operate as usual - file does indeed not exist');
+                    }
+
+                    if (FileReadTrapStreamWrapper::$autoloadLocatedFile !== null) {
+                        throw new UnexpectedValueException('FileReadTrapStreamWrapper::$autoloadLocatedFile should not be populated when file existence is checked');
+                    }
+
+                    if (@file_get_contents($nonExistingFilePath)) {
+                        throw new UnexpectedValueException('file_get_contents() should fail: file should not be readable when this stream wrapper is active');
+                    }
+
+                    if ($nonExistingFilePath !== FileReadTrapStreamWrapper::$autoloadLocatedFile) {
+                        throw new UnexpectedValueException('FileReadTrapStreamWrapper::$autoloadLocatedFile should have been populated by the failed file access');
+                    }
+
+                    return 'the value produced by the function';
+                },
+                ['file']
+            )
+        );
+
+        self::assertNull(FileReadTrapStreamWrapper::$autoloadLocatedFile);
+        self::assertNotEmpty(file_get_contents(__FILE__), 'Stream wrapper was removed, file reads work again');
+    }
+
+    public function testWillOnlyOverrideProvidedProtocols() : void
+    {
+        self::assertNull(FileReadTrapStreamWrapper::$autoloadLocatedFile);
+
+        self::assertSame(
+            'another value produced by the function',
+            FileReadTrapStreamWrapper::withStreamWrapperOverride(
+                static function () : string {
+                    if (FileReadTrapStreamWrapper::$autoloadLocatedFile !== null) {
+                        throw new UnexpectedValueException('FileReadTrapStreamWrapper::$autoloadLocatedFile should be null when being first used');
+                    }
+
+                    if (! file_exists(__FILE__)) {
+                        throw new UnexpectedValueException('file_exists() should operate as usual - stream wrapper not active');
+                    }
+
+                    if (FileReadTrapStreamWrapper::$autoloadLocatedFile !== null) {
+                        throw new UnexpectedValueException('FileReadTrapStreamWrapper::$autoloadLocatedFile should not be populated when file existence is checked');
+                    }
+
+                    if (! @file_get_contents(__FILE__)) {
+                        throw new UnexpectedValueException('file_get_contents() should work: file access not on this protocol');
+                    }
+
+                    if (FileReadTrapStreamWrapper::$autoloadLocatedFile !== null) {
+                        throw new UnexpectedValueException('FileReadTrapStreamWrapper::$autoloadLocatedFile should not have been populated: unrelated protocol');
+                    }
+
+                    return 'another value produced by the function';
+                },
+                ['http']
+            )
+        );
+
+        self::assertNull(FileReadTrapStreamWrapper::$autoloadLocatedFile);
+        self::assertNotEmpty(file_get_contents(__FILE__), 'Stream wrapper was removed, file reads work again');
+    }
+
+    public function testStreamWrapperIsRestoredWhenAnExceptionIsThrown() : void
+    {
+        $thrown = new Exception();
+
+        try {
+            self::assertSame(
+                'another value produced by the function',
+                FileReadTrapStreamWrapper::withStreamWrapperOverride(
+                    static function () use ($thrown) : string {
+                        throw $thrown;
+                    },
+                    ['http']
+                )
+            );
+
+            self::fail('No exception was raised');
+        } catch (Throwable $caught) {
+            self::assertSame($thrown, $caught);
+        }
+
+        self::assertNull(FileReadTrapStreamWrapper::$autoloadLocatedFile);
+        self::assertNotEmpty(file_get_contents(__FILE__), 'Stream wrapper was removed, file reads work again');
+    }
+
+    public function testWillRaiseWarningWhenTryingToCheckFileExistenceForNonExistingFileWithoutSilencingModifier() : void
+    {
+        self::assertTrue(
+            class_exists(Warning::class),
+            'The warning class should not be autoloaded lazily for this specific test'
+        );
+
+        $nonExistingFile = __DIR__ . uniqid('non-existing-file', true);
+
+        $this->expectWarning();
+
+        self::assertSame(
+            'another value produced by the function',
+            FileReadTrapStreamWrapper::withStreamWrapperOverride(
+                static function () use ($nonExistingFile) : string {
+                    if (file_exists($nonExistingFile)) {
+                        throw new UnexpectedValueException('file_exists() should report `false` for a non-existing file');
+                    }
+
+                    if (file_get_contents($nonExistingFile) !== false) {
+                        throw new UnexpectedValueException('file_get_contents() should report `false` for a non-existing file');
+                    }
+
+                    return 'another value produced by the function';
+                },
+                ['file']
+            )
+        );
+
+        self::assertNull(FileReadTrapStreamWrapper::$autoloadLocatedFile);
+        self::assertNotEmpty(file_get_contents(__FILE__), 'Stream wrapper was removed, file reads work again');
+    }
+}
diff --git a/test/unit/SourceLocator/Type/AutoloadSourceLocatorTest.php b/test/unit/SourceLocator/Type/AutoloadSourceLocatorTest.php
index 2187cdce..1645228a 100644
--- a/test/unit/SourceLocator/Type/AutoloadSourceLocatorTest.php
+++ b/test/unit/SourceLocator/Type/AutoloadSourceLocatorTest.php
@@ -57,9 +57,7 @@ class AutoloadSourceLocatorTest extends TestCase
         $this->classReflector = $configuration->classReflector();
     }
 
-    /**
-     * @return Reflector|MockObject
-     */
+    /** @return Reflector&MockObject */
     private function getMockReflector()
     {
         return $this->createMock(Reflector::class);
diff --git a/test/unit/SourceLocator/Type/AutoloadSourceLocatorWithoutLoadedParserDependenciesTest.php b/test/unit/SourceLocator/Type/AutoloadSourceLocatorWithoutLoadedParserDependenciesTest.php
new file mode 100644
index 00000000..8dfc4000
--- /dev/null
+++ b/test/unit/SourceLocator/Type/AutoloadSourceLocatorWithoutLoadedParserDependenciesTest.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Roave\BetterReflectionTest\SourceLocator\Type;
+
+use PhpParser\Lexer\Emulative;
+use PhpParser\ParserFactory;
+use PHPUnit\Framework\TestCase;
+use Roave\BetterReflection\Reflector\ClassReflector;
+use Roave\BetterReflection\Reflector\FunctionReflector;
+use Roave\BetterReflection\SourceLocator\Ast\Locator;
+use Roave\BetterReflection\SourceLocator\Ast\Parser\MemoizingParser;
+use Roave\BetterReflection\SourceLocator\Type\AutoloadSourceLocator;
+use Roave\BetterReflectionTest\Fixture\ExampleClass;
+use function class_exists;
+
+/** @covers \Roave\BetterReflection\SourceLocator\Type\AutoloadSourceLocator */
+class AutoloadSourceLocatorWithoutLoadedParserDependenciesTest extends TestCase
+{
+    /**
+     * @runInSeparateProcess
+     * @preserveGlobalState disabled
+     */
+    public function testCanFindClassEvenWhenParserIsNotLoadedInMemory() : void
+    {
+        self::assertFalse(
+            class_exists(MemoizingParser::class, false),
+            MemoizingParser::class . ' was not loaded into memory'
+        );
+
+        $parser            = (new ParserFactory())->create(ParserFactory::PREFER_PHP7, new Emulative([
+            'usedAttributes' => ['comments', 'startLine', 'endLine', 'startFilePos', 'endFilePos'],
+        ]));
+        $functionReflector = null;
+        $sourceLocator     = new AutoloadSourceLocator(
+            new Locator($parser, static function () use (&$functionReflector) : FunctionReflector {
+                return $functionReflector;
+            }),
+            $parser
+        );
+        $classReflector    = new ClassReflector($sourceLocator);
+        $functionReflector = new FunctionReflector($sourceLocator, $classReflector);
+        $reflection        = $classReflector->reflect(ExampleClass::class);
+
+        self::assertSame(ExampleClass::class, $reflection->getName());
+        self::assertFalse(
+            class_exists(MemoizingParser::class, false),
+            MemoizingParser::class . ' was not implicitly loaded'
+        );
+    }
+}