Skip to content

Commit c2d2c2f

Browse files
committed
AutoloadSourceLocator - still work the old way with disableRuntimeReflectionProvider=true
1 parent 7851088 commit c2d2c2f

File tree

5 files changed

+312
-6
lines changed

5 files changed

+312
-6
lines changed

build/baseline-8.0.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
parameters:
22
ignoreErrors:
3+
-
4+
message: "#^Strict comparison using \\=\\=\\= between array and false will always evaluate to false\\.$#"
5+
count: 1
6+
path: ../src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php
7+
38
-
49
message: "#^Strict comparison using \\=\\=\\= between array and false will always evaluate to false\\.$#"
510
count: 1

conf/config.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,8 @@ services:
615615

616616
-
617617
class: PHPStan\Reflection\BetterReflection\SourceLocator\AutoloadSourceLocator
618+
arguments:
619+
disableRuntimeReflectionProvider: %featureToggles.disableRuntimeReflectionProvider%
618620

619621
-
620622
class: PHPStan\Reflection\BetterReflection\SourceLocator\ComposerJsonAndInstalledJsonSourceLocatorMaker

src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
use function interface_exists;
2929
use function is_file;
3030
use function is_string;
31+
use function restore_error_handler;
32+
use function set_error_handler;
33+
use function spl_autoload_functions;
3134
use function strtolower;
3235
use function trait_exists;
3336
use const PHP_VERSION_ID;
@@ -45,6 +48,8 @@ class AutoloadSourceLocator implements SourceLocator
4548

4649
private FileNodesFetcher $fileNodesFetcher;
4750

51+
private bool $disableRuntimeReflectionProvider;
52+
4853
/** @var array<string, array<FetchedNode<Node\Stmt\ClassLike>>> */
4954
private array $classNodes = [];
5055

@@ -60,9 +65,10 @@ class AutoloadSourceLocator implements SourceLocator
6065
/** @var array<string, true> */
6166
private array $fetchedNodesByFile = [];
6267

63-
public function __construct(FileNodesFetcher $fileNodesFetcher)
68+
public function __construct(FileNodesFetcher $fileNodesFetcher, bool $disableRuntimeReflectionProvider)
6469
{
6570
$this->fileNodesFetcher = $fileNodesFetcher;
71+
$this->disableRuntimeReflectionProvider = $disableRuntimeReflectionProvider;
6672
}
6773

6874
public function locateIdentifier(Reflector $reflector, Identifier $identifier): ?Reflection
@@ -177,10 +183,8 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier):
177183
);
178184
}
179185
}
180-
181186
return null;
182187
}
183-
184188
[$potentiallyLocatedFile, $className, $startLine] = $locateResult;
185189

186190
return $this->findReflection($reflector, $potentiallyLocatedFile, new Identifier($className, $identifier->getType()), $startLine);
@@ -259,11 +263,23 @@ public function locateIdentifiersByType(Reflector $reflector, IdentifierType $id
259263
}
260264

261265
/**
266+
* Attempt to locate a class by name.
267+
*
268+
* If class already exists, simply use internal reflection API to get the
269+
* filename and store it.
270+
*
271+
* If class does not exist, we make an assumption that whatever autoloaders
272+
* that are registered will be loading a file. We then override the file://
273+
* protocol stream wrapper to "capture" the filename we expect the class to
274+
* be in, and then restore it. Note that class_exists will cause an error
275+
* that it cannot find the file, so we squelch the errors by overriding the
276+
* error handler temporarily.
277+
*
262278
* @return array{string, string, int|null}|null
263279
*/
264280
private function locateClassByName(string $className): ?array
265281
{
266-
if (class_exists($className) || interface_exists($className) || trait_exists($className)) {
282+
if (class_exists($className, !$this->disableRuntimeReflectionProvider) || interface_exists($className, !$this->disableRuntimeReflectionProvider) || trait_exists($className, !$this->disableRuntimeReflectionProvider)) {
267283
$reflection = new ReflectionClass($className);
268284
$filename = $reflection->getFileName();
269285

@@ -278,7 +294,46 @@ private function locateClassByName(string $className): ?array
278294
return [$filename, $reflection->getName(), $reflection->getStartLine() !== false ? $reflection->getStartLine() : null];
279295
}
280296

281-
return null;
297+
if (!$this->disableRuntimeReflectionProvider) {
298+
return null;
299+
}
300+
301+
$this->silenceErrors();
302+
303+
try {
304+
/** @var array{string, string, null}|null */
305+
return FileReadTrapStreamWrapper::withStreamWrapperOverride(
306+
static function () use ($className): ?array {
307+
$functions = spl_autoload_functions();
308+
if ($functions === false) {
309+
return null;
310+
}
311+
312+
foreach ($functions as $preExistingAutoloader) {
313+
$preExistingAutoloader($className);
314+
315+
/**
316+
* This static variable is populated by the side-effect of the stream wrapper
317+
* trying to read the file path when `include()` is used by an autoloader.
318+
*
319+
* This will not be `null` when the autoloader tried to read a file.
320+
*/
321+
if (FileReadTrapStreamWrapper::$autoloadLocatedFile !== null) {
322+
return [FileReadTrapStreamWrapper::$autoloadLocatedFile, $className, null];
323+
}
324+
}
325+
326+
return null;
327+
},
328+
);
329+
} finally {
330+
restore_error_handler();
331+
}
332+
}
333+
334+
private function silenceErrors(): void
335+
{
336+
set_error_handler(static fn (): bool => true);
282337
}
283338

284339
}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Reflection\BetterReflection\SourceLocator;
4+
5+
use PHPStan\ShouldNotHappenException;
6+
use function stat;
7+
use function stream_wrapper_register;
8+
use function stream_wrapper_restore;
9+
use function stream_wrapper_unregister;
10+
use const SEEK_CUR;
11+
use const SEEK_END;
12+
use const SEEK_SET;
13+
use const STREAM_URL_STAT_QUIET;
14+
15+
/**
16+
* This class will operate as a stream wrapper, intercepting any access to a file while
17+
* in operation.
18+
*
19+
* @internal DO NOT USE: this is an implementation detail of
20+
* the {@see \PHPStan\BetterReflection\SourceLocator\Type\AutoloadSourceLocator}
21+
*
22+
* phpcs:disable SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint
23+
* phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps
24+
* phpcs:disable Squiz.NamingConventions.ValidVariableName.NotCamelCaps
25+
*/
26+
final class FileReadTrapStreamWrapper
27+
{
28+
29+
private const DEFAULT_STREAM_WRAPPER_PROTOCOLS = [
30+
'file',
31+
'phar',
32+
];
33+
34+
/** @var string[]|null */
35+
private static ?array $registeredStreamWrapperProtocols;
36+
37+
public static ?string $autoloadLocatedFile = null;
38+
39+
private bool $readFromFile = false;
40+
41+
private int $seekPosition = 0;
42+
43+
/**
44+
* @param string[] $streamWrapperProtocols
45+
*
46+
* @return mixed
47+
*
48+
* @psalm-template ExecutedMethodReturnType of mixed
49+
* @psalm-param callable() : ExecutedMethodReturnType $executeMeWithinStreamWrapperOverride
50+
* @psalm-return ExecutedMethodReturnType
51+
*/
52+
public static function withStreamWrapperOverride(
53+
callable $executeMeWithinStreamWrapperOverride,
54+
array $streamWrapperProtocols = self::DEFAULT_STREAM_WRAPPER_PROTOCOLS,
55+
)
56+
{
57+
self::$registeredStreamWrapperProtocols = $streamWrapperProtocols;
58+
self::$autoloadLocatedFile = null;
59+
60+
try {
61+
foreach ($streamWrapperProtocols as $protocol) {
62+
stream_wrapper_unregister($protocol);
63+
stream_wrapper_register($protocol, self::class);
64+
}
65+
66+
$result = $executeMeWithinStreamWrapperOverride();
67+
} finally {
68+
foreach ($streamWrapperProtocols as $protocol) {
69+
stream_wrapper_restore($protocol);
70+
}
71+
}
72+
73+
self::$registeredStreamWrapperProtocols = null;
74+
self::$autoloadLocatedFile = null;
75+
76+
return $result;
77+
}
78+
79+
/**
80+
* Our wrapper simply records which file we tried to load and returns
81+
* boolean false indicating failure.
82+
*
83+
* @internal do not call this method directly! This is stream wrapper
84+
* voodoo logic that you **DO NOT** want to touch!
85+
*
86+
* @see https://php.net/manual/en/class.streamwrapper.php
87+
* @see https://php.net/manual/en/streamwrapper.stream-open.php
88+
*
89+
* @param string $path
90+
* @param string $mode
91+
* @param int $options
92+
* @param string $openedPath
93+
*/
94+
public function stream_open($path, $mode, $options, &$openedPath): bool
95+
{
96+
self::$autoloadLocatedFile = $path;
97+
$this->readFromFile = false;
98+
$this->seekPosition = 0;
99+
100+
return true;
101+
}
102+
103+
/**
104+
* Since we allow our wrapper's stream_open() to succeed, we need to
105+
* simulate a successful read so autoloaders with require() don't explode.
106+
*
107+
* @param int $count
108+
*
109+
*/
110+
public function stream_read($count): string
111+
{
112+
$this->readFromFile = true;
113+
114+
// Dummy return value that is also valid PHP for require(). We'll read
115+
// and process the file elsewhere, so it's OK to provide dummy data for
116+
// this read.
117+
return '';
118+
}
119+
120+
/**
121+
* Since we allowed the open to succeed, we should allow the close to occur
122+
* as well.
123+
*
124+
*/
125+
public function stream_close(): void
126+
{
127+
// no op
128+
}
129+
130+
/**
131+
* Required for `require_once` and `include_once` to work per PHP.net
132+
* comment referenced below. We delegate to url_stat().
133+
*
134+
* @see https://www.php.net/manual/en/function.stream-wrapper-register.php#51855
135+
*
136+
* @return mixed[]|bool
137+
*/
138+
public function stream_stat()
139+
{
140+
if (self::$autoloadLocatedFile === null) {
141+
return false;
142+
}
143+
144+
return $this->url_stat(self::$autoloadLocatedFile, STREAM_URL_STAT_QUIET);
145+
}
146+
147+
/**
148+
* url_stat is triggered by calls like "file_exists". The call to "file_exists" must not be overloaded.
149+
* This function restores the original "file" stream, issues a call to "stat" to get the real results,
150+
* and then re-registers the AutoloadSourceLocator stream wrapper.
151+
*
152+
* @internal do not call this method directly! This is stream wrapper
153+
* voodoo logic that you **DO NOT** want to touch!
154+
*
155+
* @see https://php.net/manual/en/class.streamwrapper.php
156+
* @see https://php.net/manual/en/streamwrapper.url-stat.php
157+
*
158+
* @param string $path
159+
* @param int $flags
160+
*
161+
* @return mixed[]|bool
162+
*/
163+
public function url_stat($path, $flags)
164+
{
165+
if (self::$registeredStreamWrapperProtocols === null) {
166+
throw new ShouldNotHappenException(self::class . ' not registered: cannot operate. Do not call this method directly.');
167+
}
168+
169+
foreach (self::$registeredStreamWrapperProtocols as $protocol) {
170+
stream_wrapper_restore($protocol);
171+
}
172+
173+
if (($flags & STREAM_URL_STAT_QUIET) !== 0) {
174+
$result = @stat($path);
175+
} else {
176+
$result = stat($path);
177+
}
178+
179+
foreach (self::$registeredStreamWrapperProtocols as $protocol) {
180+
stream_wrapper_unregister($protocol);
181+
stream_wrapper_register($protocol, self::class);
182+
}
183+
184+
return $result;
185+
}
186+
187+
/**
188+
* Simulates behavior of reading from an empty file.
189+
*
190+
*/
191+
public function stream_eof(): bool
192+
{
193+
return $this->readFromFile;
194+
}
195+
196+
public function stream_flush(): bool
197+
{
198+
return true;
199+
}
200+
201+
public function stream_tell(): int
202+
{
203+
return $this->seekPosition;
204+
}
205+
206+
/**
207+
* @param int $offset
208+
* @param int $whence
209+
*/
210+
public function stream_seek($offset, $whence): bool
211+
{
212+
switch ($whence) {
213+
// Behavior is the same for a zero-length file
214+
case SEEK_SET:
215+
case SEEK_END:
216+
if ($offset < 0) {
217+
return false;
218+
}
219+
$this->seekPosition = $offset;
220+
return true;
221+
222+
case SEEK_CUR:
223+
if ($offset < 0) {
224+
return false;
225+
}
226+
$this->seekPosition += $offset;
227+
return true;
228+
229+
default:
230+
return false;
231+
}
232+
}
233+
234+
/**
235+
* @param int $option
236+
* @param int $arg1
237+
* @param int $arg2
238+
*/
239+
public function stream_set_option($option, $arg1, $arg2): bool
240+
{
241+
return false;
242+
}
243+
244+
}

tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocatorTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class AutoloadSourceLocatorTest extends PHPStanTestCase
1818

1919
public function testAutoloadEverythingInFile(): void
2020
{
21-
$locator = new AutoloadSourceLocator(self::getContainer()->getByType(FileNodesFetcher::class));
21+
$locator = new AutoloadSourceLocator(self::getContainer()->getByType(FileNodesFetcher::class), false);
2222
$reflector = new DefaultReflector($locator);
2323
$aFoo = $reflector->reflectClass(AFoo::class);
2424
$this->assertNotNull($aFoo->getFileName());

0 commit comments

Comments
 (0)