Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
78.10% |
239 / 306 |
|
33.33% |
4 / 12 |
CRAP | |
0.00% |
0 / 1 |
Prefixer | |
78.10% |
239 / 306 |
|
33.33% |
4 / 12 |
201.88 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
replaceInFiles | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
56 | |||
replaceInProjectFiles | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
30 | |||
replaceInString | |
65.12% |
28 / 43 |
|
0.00% |
0 / 1 |
18.11 | |||
replaceNamespace | |
85.19% |
23 / 27 |
|
0.00% |
0 / 1 |
4.05 | |||
replaceClassname | |
83.33% |
25 / 30 |
|
0.00% |
0 / 1 |
8.30 | |||
replaceGlobalClassInsideNamedNamespace | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
1 | |||
replaceConstants | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
replaceConstant | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
replaceFunctions | |
96.77% |
30 / 31 |
|
0.00% |
0 / 1 |
4 | |||
getModifiedFiles | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
prepareRelativeNamespaces | |
93.69% |
104 / 111 |
|
0.00% |
0 / 1 |
53.70 |
1 | <?php |
2 | |
3 | namespace BrianHenryIE\Strauss\Pipeline; |
4 | |
5 | use BrianHenryIE\Strauss\Composer\ComposerPackage; |
6 | use BrianHenryIE\Strauss\Config\PrefixerConfigInterface; |
7 | use BrianHenryIE\Strauss\Files\File; |
8 | use BrianHenryIE\Strauss\Helpers\FileSystem; |
9 | use BrianHenryIE\Strauss\Helpers\NamespaceSort; |
10 | use BrianHenryIE\Strauss\Types\DiscoveredSymbols; |
11 | use BrianHenryIE\Strauss\Types\FunctionSymbol; |
12 | use BrianHenryIE\Strauss\Types\NamespaceSymbol; |
13 | use Exception; |
14 | use League\Flysystem\FilesystemException; |
15 | use PhpParser\Node; |
16 | use PhpParser\NodeTraverser; |
17 | use PhpParser\ParserFactory; |
18 | use PhpParser\PrettyPrinter\Standard; |
19 | use Psr\Log\LoggerAwareTrait; |
20 | use Psr\Log\LoggerInterface; |
21 | use Psr\Log\NullLogger; |
22 | |
23 | class Prefixer |
24 | { |
25 | use LoggerAwareTrait; |
26 | |
27 | protected PrefixerConfigInterface $config; |
28 | |
29 | protected FileSystem $filesystem; |
30 | |
31 | /** |
32 | * array<$filePath, $package> or null if the file is not from a dependency (i.e. a project file). |
33 | * |
34 | * @var array<string, ?ComposerPackage> |
35 | */ |
36 | protected array $changedFiles = array(); |
37 | |
38 | public function __construct( |
39 | PrefixerConfigInterface $config, |
40 | FileSystem $filesystem, |
41 | ?LoggerInterface $logger = null |
42 | ) { |
43 | $this->config = $config; |
44 | $this->filesystem = $filesystem; |
45 | $this->logger = $logger ?? new NullLogger(); |
46 | } |
47 | |
48 | // Don't replace a classname if there's an import for a class with the same name. |
49 | // but do replace \Classname always |
50 | |
51 | /** |
52 | * @param DiscoveredSymbols $discoveredSymbols |
53 | * ///param array<string,array{dependency:ComposerPackage,sourceAbsoluteFilepath:string,targetRelativeFilepath:string}> $phpFileArrays |
54 | * @param array<File> $files |
55 | * |
56 | * @throws FilesystemException |
57 | * @throws FilesystemException |
58 | */ |
59 | public function replaceInFiles(DiscoveredSymbols $discoveredSymbols, array $files): void |
60 | { |
61 | foreach ($files as $file) { |
62 | if ($this->filesystem->directoryExists($file->getAbsoluteTargetPath())) { |
63 | $this->logger->debug("is_dir() / nothing to do : {$file->getAbsoluteTargetPath()}"); |
64 | continue; |
65 | } |
66 | |
67 | if (! $this->filesystem->fileExists($file->getAbsoluteTargetPath())) { |
68 | $this->logger->warning("Expected file does not exist: {$file->getAbsoluteTargetPath()}"); |
69 | continue; |
70 | } |
71 | |
72 | if (!$file->isPhpFile()) { |
73 | continue; |
74 | } |
75 | |
76 | if (!$file->isDoPrefix()) { |
77 | continue; |
78 | } |
79 | |
80 | /** |
81 | * Throws an exception, but unlikely to happen. |
82 | */ |
83 | $contents = $this->filesystem->read($file->getAbsoluteTargetPath()); |
84 | |
85 | $updatedContents = $this->replaceInString($discoveredSymbols, $contents); |
86 | |
87 | $relativeFilePath = $this->filesystem->getRelativePath(dirname($this->config->getTargetDirectory()), $file->getAbsoluteTargetPath()); |
88 | |
89 | if ($updatedContents !== $contents) { |
90 | // TODO: diff here and debug log. |
91 | $file->setDidUpdate(); |
92 | $this->filesystem->write($file->getAbsoluteTargetPath(), $updatedContents); |
93 | $this->logger->info("Updated contents of file: {$relativeFilePath}"); |
94 | } else { |
95 | $this->logger->debug("No changes to file: {$relativeFilePath}"); |
96 | } |
97 | } |
98 | } |
99 | |
100 | /** |
101 | * @param DiscoveredSymbols $discoveredSymbols |
102 | * @param string[] $absoluteFilePathsArray |
103 | * |
104 | * @return void |
105 | * @throws FilesystemException |
106 | */ |
107 | public function replaceInProjectFiles(DiscoveredSymbols $discoveredSymbols, array $absoluteFilePathsArray): void |
108 | { |
109 | |
110 | foreach ($absoluteFilePathsArray as $fileAbsolutePath) { |
111 | $relativeFilePath = $this->filesystem->getRelativePath(dirname($this->config->getTargetDirectory()), $fileAbsolutePath); |
112 | |
113 | if ($this->filesystem->directoryExists($fileAbsolutePath)) { |
114 | $this->logger->debug("is_dir() / nothing to do : {$relativeFilePath}"); |
115 | continue; |
116 | } |
117 | |
118 | if (! $this->filesystem->fileExists($fileAbsolutePath)) { |
119 | $this->logger->warning("Expected file does not exist: {$relativeFilePath}"); |
120 | continue; |
121 | } |
122 | |
123 | // Throws an exception, but unlikely to happen. |
124 | $contents = $this->filesystem->read($fileAbsolutePath); |
125 | |
126 | $updatedContents = $this->replaceInString($discoveredSymbols, $contents); |
127 | |
128 | if ($updatedContents !== $contents) { |
129 | $this->changedFiles[ $fileAbsolutePath ] = null; |
130 | $this->filesystem->write($fileAbsolutePath, $updatedContents); |
131 | $this->logger->info('Updated contents of file: ' . $relativeFilePath); |
132 | } else { |
133 | $this->logger->debug('No changes to file: ' . $relativeFilePath); |
134 | } |
135 | } |
136 | } |
137 | |
138 | /** |
139 | * @param DiscoveredSymbols $discoveredSymbols |
140 | * @param string $contents |
141 | * |
142 | * @throws Exception |
143 | */ |
144 | public function replaceInString(DiscoveredSymbols $discoveredSymbols, string $contents): string |
145 | { |
146 | $classmapPrefix = $this->config->getClassmapPrefix(); |
147 | |
148 | $namespacesChanges = $discoveredSymbols->getDiscoveredNamespaces($this->config->getNamespacePrefix()); |
149 | $constants = $discoveredSymbols->getDiscoveredConstants($this->config->getConstantsPrefix()); |
150 | $functions = $discoveredSymbols->getDiscoveredFunctions(); |
151 | |
152 | $contents = $this->prepareRelativeNamespaces($contents, $namespacesChanges); |
153 | |
154 | $classesTraitsInterfaces = array_merge( |
155 | $discoveredSymbols->getDiscoveredTraits(), |
156 | $discoveredSymbols->getDiscoveredInterfaces(), |
157 | $discoveredSymbols->getAllClasses() |
158 | ); |
159 | |
160 | foreach ($classesTraitsInterfaces as $theclass) { |
161 | if (str_starts_with($theclass->getOriginalSymbol(), $classmapPrefix)) { |
162 | // Already prefixed / second scan. |
163 | continue; |
164 | } |
165 | |
166 | if ($theclass->getNamespace() !== '\\') { |
167 | $newNamespace = $namespacesChanges[$theclass->getNamespace()]; |
168 | if ($newNamespace) { |
169 | $theclass->setReplacement( |
170 | str_replace( |
171 | $newNamespace->getOriginalSymbol(), |
172 | $newNamespace->getReplacement(), |
173 | $theclass->getOriginalSymbol() |
174 | ) |
175 | ); |
176 | unset($newNamespace); |
177 | } |
178 | continue; |
179 | } |
180 | $theclass->setReplacement($classmapPrefix . $theclass->getOriginalSymbol()); |
181 | } |
182 | |
183 | foreach ($discoveredSymbols->getDiscoveredClasses($this->config->getClassmapPrefix()) as $classsname) { |
184 | if ($classmapPrefix) { |
185 | $contents = $this->replaceClassname($contents, $classsname, $classmapPrefix); |
186 | } |
187 | } |
188 | |
189 | // TODO: Move this out of the loop. |
190 | $namespacesChangesStrings = []; |
191 | foreach ($namespacesChanges as $originalNamespace => $namespaceSymbol) { |
192 | if (in_array($originalNamespace, $this->config->getExcludeNamespacesFromPrefixing())) { |
193 | $this->logger->info("Skipping namespace: $originalNamespace"); |
194 | continue; |
195 | } |
196 | $namespacesChangesStrings[$originalNamespace] = $namespaceSymbol->getReplacement(); |
197 | } |
198 | // This matters... it shouldn't. |
199 | uksort($namespacesChangesStrings, new NamespaceSort(NamespaceSort::SHORTEST)); |
200 | foreach ($namespacesChangesStrings as $originalNamespace => $replacementNamespace) { |
201 | $contents = $this->replaceNamespace($contents, $originalNamespace, $replacementNamespace); |
202 | } |
203 | |
204 | if (!is_null($this->config->getConstantsPrefix())) { |
205 | $contents = $this->replaceConstants($contents, $constants, $this->config->getConstantsPrefix()); |
206 | } |
207 | |
208 | foreach ($functions as $functionSymbol) { |
209 | $contents = $this->replaceFunctions($contents, $functionSymbol); |
210 | } |
211 | |
212 | return $contents; |
213 | } |
214 | |
215 | /** |
216 | * TODO: Test against traits. |
217 | * |
218 | * @param string $contents The text to make replacements in. |
219 | * @param string $originalNamespace |
220 | * @param string $replacement |
221 | * |
222 | * @return string The updated text. |
223 | * @throws Exception |
224 | */ |
225 | public function replaceNamespace(string $contents, string $originalNamespace, string $replacement): string |
226 | { |
227 | |
228 | $searchNamespace = '\\'.rtrim($originalNamespace, '\\') . '\\'; |
229 | $searchNamespace = str_replace('\\\\', '\\', $searchNamespace); |
230 | $searchNamespace = str_replace('\\', '\\\\{0,2}', $searchNamespace); |
231 | |
232 | $pattern = " |
233 | / # Start the pattern |
234 | ( |
235 | ^\s* # start of the string |
236 | |\\n\s* # start of the line |
237 | |(<?php\s+namespace|^\s*namespace|[\r\n]+\s*namespace)\s+ # the namespace keyword |
238 | |use\s+ # the use keyword |
239 | |use\s+function\s+ # the use function syntax |
240 | |new\s+ |
241 | |static\s+ |
242 | |\" # inside a string that does not contain spaces - needs work |
243 | |' # right now its just inside a string that doesnt start with a space |
244 | |implements\s+ |
245 | |extends\s+ # when the class being extended is namespaced inline |
246 | |return\s+ |
247 | |instanceof\s+ # when checking the class type of an object in a conditional |
248 | |\(\s* # inside a function declaration as the first parameters type |
249 | |,\s* # inside a function declaration as a subsequent parameter type |
250 | |\.\s* # as part of a concatenated string |
251 | |=\s* # as the value being assigned to a variable |
252 | |\*\s+@\w+\s* # In a comments param etc |
253 | |&\s* # a static call as a second parameter of an if statement |
254 | |\|\s* |
255 | |!\s* # negating the result of a static call |
256 | |=>\s* # as the value in an associative array |
257 | |\[\s* # In a square array |
258 | |\?\s* # In a ternary operator |
259 | |:\s* # In a ternary operator |
260 | |< # In a generic type declaration |
261 | |\(string\)\s* # casting a namespaced class to a string |
262 | ) |
263 | @? # Maybe preceded by the @ symbol for error suppression |
264 | (?<searchNamespace> |
265 | {$searchNamespace} # followed by the namespace to replace |
266 | ) |
267 | (?!:) # Not followed by : which would only be valid after a classname |
268 | ( |
269 | \s*; # followed by a semicolon |
270 | |\s*{ # or an opening brace for multiple namespaces per file |
271 | |\\\\{1,2}[a-zA-Z0-9_\x7f-\xff]{1,} # or a classname no slashes |
272 | |\s+as # or the keyword as |
273 | |\" # or quotes |
274 | |' # or single quote |
275 | |: # or a colon to access a static |
276 | |\\\\{ |
277 | |> # In a generic type declaration (end) |
278 | ) |
279 | /Ux"; // U: Non-greedy matching, x: ignore whitespace in pattern. |
280 | |
281 | $replacingFunction = function ($matches) use ($originalNamespace, $replacement) { |
282 | $singleBackslash = '\\'; |
283 | $doubleBackslash = '\\\\'; |
284 | |
285 | if (false !== strpos($matches['0'], $doubleBackslash)) { |
286 | $originalNamespace = str_replace($singleBackslash, $doubleBackslash, $originalNamespace); |
287 | $replacement = str_replace($singleBackslash, $doubleBackslash, $replacement); |
288 | } |
289 | |
290 | return str_replace($originalNamespace, $replacement, $matches[0]); |
291 | }; |
292 | |
293 | $result = preg_replace_callback($pattern, $replacingFunction, $contents); |
294 | |
295 | $matchingError = preg_last_error(); |
296 | if (0 !== $matchingError) { |
297 | $message = "Matching error {$matchingError}"; |
298 | if (PREG_BACKTRACK_LIMIT_ERROR === $matchingError) { |
299 | $message = 'Preg Backtrack limit was exhausted!'; |
300 | } |
301 | throw new Exception($message); |
302 | } |
303 | |
304 | // For prefixed functions which do not begin with a backslash, add one. |
305 | // I'm not certain this is a good idea. |
306 | // @see https://github.com/BrianHenryIE/strauss/issues/65 |
307 | $functionReplacingPattern = '/\\\\?('.preg_quote(ltrim($replacement, '\\'), '/').'\\\\(?:[a-zA-Z0-9_\x7f-\xff]+\\\\)*[a-zA-Z0-9_\x7f-\xff]+\\()/'; |
308 | |
309 | return preg_replace( |
310 | $functionReplacingPattern, |
311 | "\\\\$1", |
312 | $result |
313 | ); |
314 | } |
315 | |
316 | /** |
317 | * In a namespace: |
318 | * * use \Classname; |
319 | * * new \Classname() |
320 | * |
321 | * In a global namespace: |
322 | * * new Classname() |
323 | * |
324 | * @param string $contents |
325 | * @param string $originalClassname |
326 | * @param string $classnamePrefix |
327 | * |
328 | * @throws Exception |
329 | */ |
330 | public function replaceClassname(string $contents, string $originalClassname, string $classnamePrefix): string |
331 | { |
332 | $searchClassname = preg_quote($originalClassname, '/'); |
333 | |
334 | // This could be more specific if we could enumerate all preceding and proceeding words ("new", "("...). |
335 | $pattern = ' |
336 | / # Start the pattern |
337 | (^\s*namespace|\r\n\s*namespace)\s+[a-zA-Z0-9_\x7f-\xff\\\\]+\s*{(.*?)(namespace|\z) |
338 | # Look for a preceding namespace declaration, up until a |
339 | # potential second namespace declaration. |
340 | | # if found, match that much before continuing the search on |
341 | # the remainder of the string. |
342 | (^\s*namespace|\r\n\s*namespace)\s+[a-zA-Z0-9_\x7f-\xff\\\\]+\s*;(.*) # Skip lines just declaring the namespace. |
343 | | |
344 | ([^a-zA-Z0-9_\x7f-\xff\$\\\])('. $searchClassname . ')([^a-zA-Z0-9_\x7f-\xff\\\]) # outside a namespace the class will not be prefixed with a slash |
345 | |
346 | /xsm'; // # x: ignore whitespace in regex. s dot matches newline, m: ^ and $ match start and end of line |
347 | |
348 | $replacingFunction = function ($matches) use ($originalClassname, $classnamePrefix) { |
349 | |
350 | // If we're inside a namespace other than the global namespace: |
351 | if (1 === preg_match('/\s*namespace\s+[a-zA-Z0-9_\x7f-\xff\\\\]+[;{\s\n]{1}.*/', $matches[0])) { |
352 | return $this->replaceGlobalClassInsideNamedNamespace( |
353 | $matches[0], |
354 | $originalClassname, |
355 | $classnamePrefix |
356 | ); |
357 | } else { |
358 | $newContents = ''; |
359 | foreach ($matches as $index => $captured) { |
360 | if (0 === $index) { |
361 | continue; |
362 | } |
363 | |
364 | if ($captured == $originalClassname) { |
365 | $newContents .= $classnamePrefix; |
366 | } |
367 | |
368 | $newContents .= $captured; |
369 | } |
370 | return $newContents; |
371 | } |
372 | // return $matches[1] . $matches[2] . $matches[3] . $classnamePrefix . $originalClassname . $matches[5]; |
373 | }; |
374 | |
375 | $result = preg_replace_callback($pattern, $replacingFunction, $contents); |
376 | |
377 | if (is_null($result)) { |
378 | throw new Exception('preg_replace_callback returned null'); |
379 | } |
380 | |
381 | $matchingError = preg_last_error(); |
382 | if (0 !== $matchingError) { |
383 | $message = "Matching error {$matchingError}"; |
384 | if (PREG_BACKTRACK_LIMIT_ERROR === $matchingError) { |
385 | $message = 'Backtrack limit was exhausted!'; |
386 | } |
387 | throw new Exception($message); |
388 | } |
389 | |
390 | return $result; |
391 | } |
392 | |
393 | /** |
394 | * Pass in a string and look for \Classname instances. |
395 | * |
396 | * @param string $contents |
397 | * @param string $originalClassname |
398 | * @param string $classnamePrefix |
399 | * @return string |
400 | */ |
401 | protected function replaceGlobalClassInsideNamedNamespace( |
402 | string $contents, |
403 | string $originalClassname, |
404 | string $classnamePrefix |
405 | ): string { |
406 | $replacement = $classnamePrefix . $originalClassname; |
407 | |
408 | // use Prefixed_Class as Class; |
409 | $usePattern = '/ |
410 | (\s*use\s+) |
411 | ('.$originalClassname.') # Followed by the classname |
412 | \s*; |
413 | /x'; // # x: ignore whitespace in regex. |
414 | |
415 | $contents = preg_replace_callback( |
416 | $usePattern, |
417 | function ($matches) use ($replacement) { |
418 | return $matches[1] . $replacement . ' as '. $matches[2] . ';'; |
419 | }, |
420 | $contents |
421 | ); |
422 | |
423 | $bodyPattern = |
424 | '/([^a-zA-Z0-9_\x7f-\xff] # Not a class character |
425 | \\\) # Followed by a backslash to indicate global namespace |
426 | ('.$originalClassname.') # Followed by the classname |
427 | ([^\\\;]{1}) # Not a backslash or semicolon which might indicate a namespace |
428 | /x'; // # x: ignore whitespace in regex. |
429 | |
430 | return preg_replace_callback( |
431 | $bodyPattern, |
432 | function ($matches) use ($replacement) { |
433 | return $matches[1] . $replacement . $matches[3]; |
434 | }, |
435 | $contents |
436 | ) ?? $contents; // TODO: If this happens, it should raise an exception. |
437 | } |
438 | |
439 | /** |
440 | * TODO: This should be split and brought to FileScanner. |
441 | * |
442 | * @param string $contents |
443 | * @param string[] $originalConstants |
444 | * @param string $prefix |
445 | */ |
446 | protected function replaceConstants(string $contents, array $originalConstants, string $prefix): string |
447 | { |
448 | |
449 | foreach ($originalConstants as $constant) { |
450 | $contents = $this->replaceConstant($contents, $constant, $prefix . $constant); |
451 | } |
452 | |
453 | return $contents; |
454 | } |
455 | |
456 | protected function replaceConstant(string $contents, string $originalConstant, string $replacementConstant): string |
457 | { |
458 | return str_replace($originalConstant, $replacementConstant, $contents); |
459 | } |
460 | |
461 | protected function replaceFunctions(string $contents, FunctionSymbol $functionSymbol): string |
462 | { |
463 | $originalFunctionString = $functionSymbol->getOriginalSymbol(); |
464 | $replacementFunctionString = $functionSymbol->getReplacement(); |
465 | |
466 | if ($originalFunctionString === $replacementFunctionString) { |
467 | return $contents; |
468 | } |
469 | |
470 | $functionsUsingCallable = [ |
471 | 'function_exists', |
472 | 'call_user_func', |
473 | 'call_user_func_array', |
474 | 'forward_static_call', |
475 | 'forward_static_call_array', |
476 | 'register_shutdown_function', |
477 | 'register_tick_function', |
478 | 'unregister_tick_function', |
479 | ]; |
480 | // TODO: Immediately surrounded by quotes is sometimes valid, e.g. passing a callable, but not always. |
481 | // Log cases like this and present a list to users. Maybe CLI confirmation to replace? |
482 | |
483 | $pattern = '/ |
484 | (\s*use\s+function\s+)('.preg_quote($originalFunctionString, '/').')(\s+as|\s+;) # use function as |
485 | | |
486 | |('.implode('|', $functionsUsingCallable).')(\s*\(\s*[\'"])('.preg_quote($originalFunctionString, '/').')([\'"]) # function related calls without closing bracket |
487 | | |
488 | (\s*function\s+)('.preg_quote($originalFunctionString, '/').')(\s*\() # function declaration |
489 | | |
490 | ([;\s]+)('.preg_quote($originalFunctionString, '/').')(\s*\() # function call |
491 | /x'; // x: ignore whitespace in regex. |
492 | |
493 | return preg_replace_callback( |
494 | $pattern, |
495 | function ($matches) use ($originalFunctionString, $replacementFunctionString) { |
496 | foreach ($matches as $index => $match) { |
497 | if ($match == $originalFunctionString) { |
498 | $matches[$index] = $replacementFunctionString; |
499 | } |
500 | } |
501 | unset($matches[0]); |
502 | return implode('', $matches); |
503 | }, |
504 | $contents |
505 | ); |
506 | } |
507 | |
508 | /** |
509 | * TODO: This should be a function on {@see DiscoveredFiles}. |
510 | * |
511 | * @return array<string, ComposerPackage> |
512 | */ |
513 | public function getModifiedFiles(): array |
514 | { |
515 | return $this->changedFiles; |
516 | } |
517 | |
518 | /** |
519 | * In the case of `use Namespaced\Traitname;` by `nette/latte`, the trait uses the full namespace but it is not |
520 | * preceded by a backslash. When everything is moved up a namespace level, this is a problem. I think being |
521 | * explicit about the namespace being a full namespace rather than a relative one should fix this. |
522 | * |
523 | * We will scan the file for `use Namespaced\Traitname` and replace it with `use \Namespaced\Traitname;`. |
524 | * |
525 | * @see https://github.com/nette/latte/blob/0ac0843a459790d471821f6a82f5d13db831a0d3/src/Latte/Loaders/FileLoader.php#L20 |
526 | * |
527 | * @param string $phpFileContent |
528 | * @param NamespaceSymbol[] $discoveredNamespaceSymbols |
529 | */ |
530 | protected function prepareRelativeNamespaces(string $phpFileContent, array $discoveredNamespaceSymbols): string |
531 | { |
532 | $parser = (new ParserFactory())->createForNewestSupportedVersion(); |
533 | |
534 | $ast = $parser->parse($phpFileContent); |
535 | |
536 | $traverser = new NodeTraverser(); |
537 | $visitor = new class($discoveredNamespaceSymbols) extends \PhpParser\NodeVisitorAbstract { |
538 | |
539 | public int $countChanges = 0; |
540 | protected array $discoveredNamespaces; |
541 | |
542 | protected Node $lastNode; |
543 | |
544 | /** |
545 | * The list of `use Namespace\Subns;` statements in the file. |
546 | * |
547 | * @var string[] |
548 | */ |
549 | protected array $using = []; |
550 | |
551 | public function __construct(array $discoveredNamespaceSymbols) |
552 | { |
553 | |
554 | $this->discoveredNamespaces = array_map( |
555 | fn(NamespaceSymbol $symbol) => $symbol->getOriginalSymbol(), |
556 | $discoveredNamespaceSymbols |
557 | ); |
558 | } |
559 | |
560 | public function leaveNode(Node $node) |
561 | { |
562 | |
563 | if ($node instanceof \PhpParser\Node\Stmt\Namespace_) { |
564 | $this->using[] = $node->name->name; |
565 | $this->lastNode = $node; |
566 | return $node; |
567 | } |
568 | // Probably the namespace declaration |
569 | if (empty($this->lastNode) && $node instanceof \PhpParser\Node\Name) { |
570 | $this->using[] = $node->name; |
571 | $this->lastNode = $node; |
572 | return $node; |
573 | } |
574 | if ($node instanceof \PhpParser\Node\Name) { |
575 | return $node; |
576 | } |
577 | if ($node instanceof \PhpParser\Node\Stmt\Use_) { |
578 | foreach ($node->uses as $use) { |
579 | $use->name->name = ltrim($use->name->name, '\\'); |
580 | $this->using[] = $use->name->name; |
581 | } |
582 | $this->lastNode = $node; |
583 | return $node; |
584 | } |
585 | if ($node instanceof \PhpParser\Node\UseItem) { |
586 | return $node; |
587 | } |
588 | |
589 | $nameNodes = []; |
590 | |
591 | $docComment = $node->getDocComment(); |
592 | if ($docComment) { |
593 | foreach ($this->discoveredNamespaces as $namespace) { |
594 | $updatedDocCommentText = preg_replace( |
595 | '/(.*\*\s*@\w+\s+)('.preg_quote($namespace, '/').')/', |
596 | '$1\\\\$2', |
597 | $docComment->getText(), |
598 | 1, |
599 | $count |
600 | ); |
601 | if ($count > 0) { |
602 | $this->countChanges ++; |
603 | $node->setDocComment(new \PhpParser\Comment\Doc($updatedDocCommentText)); |
604 | break; |
605 | } |
606 | } |
607 | } |
608 | |
609 | if ($node instanceof \PhpParser\Node\Stmt\TraitUse) { |
610 | $nameNodes = array_merge($nameNodes, $node->traits); |
611 | } |
612 | |
613 | if ($node instanceof \PhpParser\Node\Param |
614 | && $node->type instanceof \PhpParser\Node\Name |
615 | && !($node->type instanceof \PhpParser\Node\Name\FullyQualified)) { |
616 | $nameNodes[] = $node->type; |
617 | } |
618 | |
619 | if ($node instanceof \PhpParser\Node\NullableType |
620 | && $node->type instanceof \PhpParser\Node\Name |
621 | && !($node->type instanceof \PhpParser\Node\Name\FullyQualified)) { |
622 | $nameNodes[] = $node->type; |
623 | } |
624 | |
625 | if ($node instanceof \PhpParser\Node\Stmt\ClassMethod |
626 | && $node->returnType instanceof \PhpParser\Node\Name |
627 | && !($node->returnType instanceof \PhpParser\Node\Name\FullyQualified)) { |
628 | $nameNodes[] = $node->returnType; |
629 | } |
630 | |
631 | if ($node instanceof \PhpParser\Node\Expr\ClassConstFetch |
632 | && $node->class instanceof \PhpParser\Node\Name |
633 | && !($node->class instanceof \PhpParser\Node\Name\FullyQualified)) { |
634 | $nameNodes[] = $node->class; |
635 | } |
636 | |
637 | if ($node instanceof \PhpParser\Node\Expr\StaticPropertyFetch |
638 | && $node->class instanceof \PhpParser\Node\Name |
639 | && !($node->class instanceof \PhpParser\Node\Name\FullyQualified)) { |
640 | $nameNodes[] = $node->class; |
641 | } |
642 | |
643 | if (property_exists($node, 'name') |
644 | && $node->name instanceof \PhpParser\Node\Name |
645 | && !($node->name instanceof \PhpParser\Node\Name\FullyQualified) |
646 | ) { |
647 | $nameNodes[] = $node->name; |
648 | } |
649 | |
650 | if ($node instanceof \PhpParser\Node\Expr\StaticCall) { |
651 | if (!method_exists($node->class, 'isFullyQualified') || !$node->class->isFullyQualified()) { |
652 | $nameNodes[] = $node->class; |
653 | } |
654 | } |
655 | |
656 | if ($node instanceof \PhpParser\Node\Stmt\TryCatch) { |
657 | foreach ($node->catches as $catch) { |
658 | foreach ($catch->types as $catchType) { |
659 | if ($catchType instanceof \PhpParser\Node\Name |
660 | && !($catchType instanceof \PhpParser\Node\Name\FullyQualified) |
661 | ) { |
662 | $nameNodes[] = $catchType; |
663 | } |
664 | } |
665 | } |
666 | } |
667 | |
668 | if ($node instanceof \PhpParser\Node\Stmt\Class_) { |
669 | foreach ($node->implements as $implement) { |
670 | if ($implement instanceof \PhpParser\Node\Name |
671 | && !($implement instanceof \PhpParser\Node\Name\FullyQualified)) { |
672 | $nameNodes[] = $implement; |
673 | } |
674 | } |
675 | } |
676 | if ($node instanceof \PhpParser\Node\Expr\Instanceof_ |
677 | && $node->class instanceof \PhpParser\Node\Name |
678 | && !($node->class instanceof \PhpParser\Node\Name\FullyQualified)) { |
679 | $nameNodes[] = $node->class; |
680 | } |
681 | |
682 | foreach ($nameNodes as $nameNode) { |
683 | if (!property_exists($nameNode, 'name')) { |
684 | continue; |
685 | } |
686 | // If the name contains a `\` but does not begin with one, it may be a relative namespace; |
687 | if (false !== strpos($nameNode->name, '\\') && 0 !== strpos($nameNode->name, '\\')) { |
688 | $parts = explode('\\', $nameNode->name); |
689 | array_pop($parts); |
690 | $namespace = implode('\\', $parts); |
691 | if (in_array($namespace, $this->discoveredNamespaces)) { |
692 | $nameNode->name = '\\' . $nameNode->name; |
693 | $this->countChanges ++; |
694 | } else { |
695 | foreach ($this->using as $namespaceBase) { |
696 | if (in_array($namespaceBase . '\\' . $namespace, $this->discoveredNamespaces)) { |
697 | $nameNode->name = '\\' . $namespaceBase . '\\' . $nameNode->name; |
698 | $this->countChanges ++; |
699 | break; |
700 | } |
701 | } |
702 | } |
703 | } |
704 | } |
705 | $this->lastNode = $node; |
706 | return $node; |
707 | } |
708 | }; |
709 | $traverser->addVisitor($visitor); |
710 | |
711 | $modifiedStmts = $traverser->traverse($ast); |
712 | |
713 | $updatedContent = (new Standard())->prettyPrintFile($modifiedStmts); |
714 | |
715 | $updatedContent = str_replace('namespace \\', 'namespace ', $updatedContent); |
716 | $updatedContent = str_replace('use \\\\', 'use \\', $updatedContent); |
717 | |
718 | return $visitor->countChanges == 0 |
719 | ? $phpFileContent |
720 | : $updatedContent; |
721 | } |
722 | } |