1010use PHPStan \BetterReflection \SourceLocator \Type \SourceLocator ;
1111use function class_exists ;
1212use function function_exists ;
13+ use function get_included_files ;
14+ use function in_array ;
1315use function interface_exists ;
1416use function PHPStan \autoloadFunctions ;
1517use function PHPStan \autoloadFunctionsPrependedToComposer ;
18+ use function restore_error_handler ;
19+ use function set_error_handler ;
1620use function trait_exists ;
1721
1822final class AutoloadFunctionsSourceLocator implements SourceLocator
@@ -43,29 +47,31 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier):
4347 return null ;
4448 }
4549
46- // If the name is already a defined function, this locator must not run the bootstrap
47- // autoloaders for it: a catch-all autoloader (e.g. PHP_CodeSniffer's, which falls back to
48- // Composer's findFile()) would resolve the name to the function's own file and plain-include
49- // it a second time - it was loaded once already, e.g. by a package that ships one function
50- // per PSR-4 path and requires it from its bootstrap - fatally redeclaring the function.
51- // Returning null only declines this locator; a class and a function may share a name in PHP,
52- // and a class that genuinely exists under this name in another file is still located by the
53- // later source locators in the chain. See https://github.com/phpstan/phpstan/issues/14988
54- if (function_exists ($ className )) {
55- return null ;
56- }
57-
5850 $ autoloadFunctions = $ this ->prependedToComposer
5951 ? autoloadFunctionsPrependedToComposer ()
6052 : autoloadFunctions ();
53+
54+ if ($ autoloadFunctions === []) {
55+ return null ;
56+ }
57+
58+ if (function_exists ($ className )) {
59+ if ($ this ->wouldReIncludeALoadedFile ($ autoloadFunctions , $ className )) {
60+ return null ;
61+ }
62+
63+ // The trap intercepts file reads, not execution, so the probe ran the autoloaders for
64+ // real. One that defines the class without reading a file - class_alias(), eval() -
65+ // has already done its work, and calling it again would redeclare what it defined.
66+ if (class_exists ($ className , false ) || interface_exists ($ className , false ) || trait_exists ($ className , false )) {
67+ return $ this ->locateWithoutAutoloading ($ reflector , $ identifier );
68+ }
69+ }
70+
6171 foreach ($ autoloadFunctions as $ autoloadFunction ) {
6272 $ autoloadFunction ($ className );
63- $ reflection = $ this ->autoloadSourceLocator ->locateIdentifier ($ reflector , $ identifier );
64- if ($ reflection !== null ) {
65- return $ reflection ;
66- }
6773
68- $ reflection = $ this ->reflectionClassSourceLocator -> locateIdentifier ($ reflector , $ identifier );
74+ $ reflection = $ this ->locateWithoutAutoloading ($ reflector , $ identifier );
6975 if ($ reflection !== null ) {
7076 return $ reflection ;
7177 }
@@ -74,6 +80,76 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier):
7480 return null ;
7581 }
7682
83+ private function locateWithoutAutoloading (Reflector $ reflector , Identifier $ identifier ): ?Reflection
84+ {
85+ $ reflection = $ this ->autoloadSourceLocator ->locateIdentifier ($ reflector , $ identifier );
86+ if ($ reflection !== null ) {
87+ return $ reflection ;
88+ }
89+
90+ return $ this ->reflectionClassSourceLocator ->locateIdentifier ($ reflector , $ identifier );
91+ }
92+
93+ /**
94+ * Whether running these autoloaders for $className would include a file that is loaded already.
95+ *
96+ * A function of this name exists, so an autoloader that maps names to paths - a catch-all one
97+ * like PHP_CodeSniffer's, falling back to Composer's findFile() - can resolve this *class* name
98+ * to the *function's* own file. Including that file a second time fatally redeclares the
99+ * function, which is what https://github.com/phpstan/phpstan/issues/14988 reported.
100+ *
101+ * Probing under the file-read trap answers which file the autoloaders would read without
102+ * executing it, so only that case is declined. Declining on the name alone would also block
103+ * class names that merely coincide with a function - classes and functions live in separate
104+ * symbol spaces, and Laravel's facade aliases (Cache, File, Str, ...) collide with the global
105+ * helpers cache(), file() and str(). See https://github.com/phpstan/phpstan/issues/15102
106+ *
107+ * @param array<int, callable(string): void> $autoloadFunctions
108+ */
109+ private function wouldReIncludeALoadedFile (array $ autoloadFunctions , string $ className ): bool
110+ {
111+ set_error_handler (static fn (): bool => true );
112+
113+ try {
114+ $ locatedFiles = FileReadTrapStreamWrapper::withStreamWrapperOverride (
115+ static function () use ($ autoloadFunctions , $ className ): array {
116+ foreach ($ autoloadFunctions as $ autoloadFunction ) {
117+ $ autoloadFunction ($ className );
118+
119+ // Stop as soon as the name is defined, the way spl_autoload_call() does:
120+ // a later autoloader must not get the chance to resolve a name that is
121+ // already taken care of. Under the trap a file read cannot define
122+ // anything, so this means the autoloader defined it by itself.
123+ if (class_exists ($ className , false ) || interface_exists ($ className , false ) || trait_exists ($ className , false )) {
124+ return [];
125+ }
126+
127+ if (FileReadTrapStreamWrapper::$ autoloadLocatedFiles !== []) {
128+ return FileReadTrapStreamWrapper::$ autoloadLocatedFiles ;
129+ }
130+ }
131+
132+ return [];
133+ },
134+ );
135+ } finally {
136+ restore_error_handler ();
137+ }
138+
139+ if ($ locatedFiles === []) {
140+ return false ;
141+ }
142+
143+ $ includedFiles = get_included_files ();
144+ foreach ($ locatedFiles as $ locatedFile ) {
145+ if (in_array ($ locatedFile , $ includedFiles , true )) {
146+ return true ;
147+ }
148+ }
149+
150+ return false ;
151+ }
152+
77153 #[Override]
78154 public function locateIdentifiersByType (Reflector $ reflector , IdentifierType $ identifierType ): array
79155 {
0 commit comments