Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.53% covered (warning)
81.53%
203 / 249
35.29% covered (danger)
35.29%
6 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileSymbolScanner
81.53% covered (warning)
81.53%
203 / 249
35.29% covered (danger)
35.29%
6 / 17
163.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 add
75.00% covered (warning)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
5.39
 findInFiles
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
9.14
 find
83.93% covered (warning)
83.93%
47 / 56
0.00% covered (danger)
0.00%
0 / 1
15.93
 splitByNamespace
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
14.51
 addDiscoveredClassChange
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
4.91
 addDiscoveredNamespaceChange
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getBuiltIns
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 loadBuiltIns
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 isBuiltInSymbol
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getParser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrettyPrinter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parsePhpCode
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
4.00
 getNamespaceDeclarations
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
17.69
 hasMalformedNamespaceDeclaration
43.75% covered (danger)
43.75%
7 / 16
0.00% covered (danger)
0.00%
0 / 1
27.80
 nextSignificantTokenIndex
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 isNamespaceNameToken
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2/**
3 * The purpose of this class is only to find changes that should be made.
4 * i.e. classes and namespaces to change.
5 * Those recorded are updated in a later step.
6 */
7
8namespace BrianHenryIE\Strauss\Pipeline;
9
10use BrianHenryIE\SimplePhpParser\Model\PHPClass;
11use BrianHenryIE\SimplePhpParser\Model\PHPConst;
12use BrianHenryIE\SimplePhpParser\Model\PHPFunction;
13use BrianHenryIE\SimplePhpParser\Parsers\Helper\ParserContainer;
14use BrianHenryIE\SimplePhpParser\Parsers\Helper\ParserErrorHandler;
15use BrianHenryIE\SimplePhpParser\Parsers\Helper\Utils;
16use BrianHenryIE\SimplePhpParser\Parsers\PhpCodeParser;
17use BrianHenryIE\SimplePhpParser\Parsers\Visitors\ASTVisitor;
18use BrianHenryIE\Strauss\Composer\ComposerPackage;
19use BrianHenryIE\Strauss\Config\FileSymbolScannerConfigInterface;
20use BrianHenryIE\Strauss\Files\DiscoveredFiles;
21use BrianHenryIE\Strauss\Files\FileBase;
22use BrianHenryIE\Strauss\Files\FileWithDependency;
23use BrianHenryIE\Strauss\Helpers\Flysystem\FileSystem;
24use BrianHenryIE\Strauss\Helpers\ParserErrorException;
25use BrianHenryIE\Strauss\Types\ClassSymbol;
26use BrianHenryIE\Strauss\Types\ConstantSymbol;
27use BrianHenryIE\Strauss\Types\DiscoveredSymbol;
28use BrianHenryIE\Strauss\Types\DiscoveredSymbols;
29use BrianHenryIE\Strauss\Types\FunctionSymbol;
30use BrianHenryIE\Strauss\Types\InterfaceSymbol;
31use BrianHenryIE\Strauss\Types\NamespaceSymbol;
32use BrianHenryIE\Strauss\Types\TraitSymbol;
33use League\Flysystem\FilesystemException;
34use PhpParser\Node;
35use PhpParser\Parser;
36use PhpParser\ParserFactory;
37use PhpParser\PrettyPrinter\Standard;
38use Psr\Log\LoggerAwareTrait;
39use Psr\Log\LoggerInterface;
40use Psr\Log\NullLogger;
41
42class FileSymbolScanner
43{
44    use LoggerAwareTrait;
45
46    /**
47     * @var array<class-string<DiscoveredSymbol>,string>
48     */
49    private const SYMBOL_LOG_TYPES = [
50        ClassSymbol::class => 'class',
51        ConstantSymbol::class => 'constant',
52        FunctionSymbol::class => 'function',
53        InterfaceSymbol::class => 'interface',
54        NamespaceSymbol::class => 'namespace',
55        TraitSymbol::class => 'trait',
56    ];
57
58    protected DiscoveredSymbols $discoveredSymbols;
59
60    protected FileSystem $filesystem;
61
62    protected FileSymbolScannerConfigInterface $config;
63
64    /** @var string[] */
65    protected array $builtIns = [];
66
67    protected ?Parser $parser = null;
68    protected ?Standard $prettyPrinter = null;
69
70    /**
71     * @var array<string,bool>
72     */
73    protected array $loggedSymbols = [];
74
75    /**
76     * @var array<string,bool>
77     */
78    protected array $builtInsLookup = [];
79
80    /**
81     * FileScanner constructor.
82     */
83    public function __construct(
84        FileSymbolScannerConfigInterface $config,
85        DiscoveredSymbols $discoveredSymbols,
86        FileSystem $filesystem,
87        ?LoggerInterface $logger = null
88    ) {
89        $this->config = $config;
90        $this->discoveredSymbols = $discoveredSymbols;
91        $this->filesystem = $filesystem;
92        $this->logger = $logger ?? new NullLogger();
93    }
94
95    protected function add(DiscoveredSymbol $symbol, ?FileBase $file = null): void
96    {
97        if (in_array($symbol->getOriginalFqdnName(), $this->getBuiltIns())) {
98            $this->logger->debug('Skipping built-in symbol {symbolName}, possible a polyfill.', [
99                'symbolName' => $symbol->getOriginalLocalName(),
100            ]);
101            return;
102        }
103
104        $this->discoveredSymbols->add($symbol);
105
106        if ($file instanceof FileWithDependency) {
107            $file->getDependency()->addDiscoveredSymbol($symbol);
108        }
109
110        $level = in_array($symbol->getOriginalFqdnName(), $this->loggedSymbols) ? 'debug' : 'info';
111        $newText = in_array($symbol->getOriginalFqdnName(), $this->loggedSymbols) ? '' : 'new ';
112
113        $this->loggedSymbols[] = $symbol->getOriginalFqdnName();
114
115        $this->logger->log(
116            $level,
117            sprintf(
118                "Found %s%s:::%s",
119                $newText,
120                // From `BrianHenryIE\Strauss\Types\TraitSymbol` -> `trait`
121                strtolower(str_replace('Symbol', '', array_reverse(explode('\\', get_class($symbol)))[0])),
122                $symbol->getOriginalFqdnName()
123            )
124        );
125    }
126
127    /**
128     * @throws FilesystemException
129     */
130    public function findInFiles(DiscoveredFiles $files): DiscoveredSymbols
131    {
132        $packagesToPrefixLookup = array_fill_keys(array_keys($this->config->getPackagesToPrefix()), true);
133        $projectDirectory = $this->config->getProjectAbsolutePath();
134
135        foreach ($files->getFiles() as $file) {
136            if ($file instanceof FileWithDependency
137                && !in_array($file->getDependency()->getPackageName(), array_keys($this->config->getPackagesToPrefix()))) {
138                    /**
139                     * We will not prefix symbols found in this file because it is not in a default or listed package.
140                     *
141                     * TODO: Move this logic to {@see MarkSymbolsForRenaming}.
142                     */
143                    $file->setDoPrefix(false);
144//                    continue;
145            }
146
147            if ($file instanceof FileWithDependency
148                && !isset($packagesToPrefixLookup[$file->getDependency()->getPackageName()])
149            ) {
150                $doPrefix = false;
151                $file->setDoPrefix($doPrefix);
152            }
153
154            $relativeFilePath =
155                $file instanceof FileWithDependency
156                    ? $file->getVendorRelativePath()
157                    : $this->filesystem->getRelativePath($projectDirectory, $file->getSourcePath());
158
159            if (!$file->isPhpFile()) {
160                $file->setDoPrefix(false);
161                $this->logger->debug("Skipping non-PHP file:::". $relativeFilePath);
162                continue;
163            }
164
165            $this->logger->info("Scanning file:::" . $relativeFilePath);
166            $this->find(
167                /**
168                 * "one unreadable file cancels scanning of all remaining files with no per-file error handling."
169                 * I think this is desirable, since we just ran the file list a moment ago, if something is unreadable, that's a show stopper.
170                 */
171                $this->filesystem->read($file->getSourcePath()),
172                $file,
173                $file instanceof FileWithDependency ? $file->getDependency() : null
174            );
175        }
176
177        return $this->discoveredSymbols;
178    }
179
180    protected function find(string $contents, FileBase $file, ?ComposerPackage $package = null): void
181    {
182        $namespaces = $this->splitByNamespace($contents);
183        PhpCodeParser::$classExistsAutoload = false;
184
185        foreach ($namespaces as $namespaceName => $contents) {
186            $namespaceSymbol = $this->addDiscoveredNamespaceChange($namespaceName, $file, $package);
187            try {
188                $phpCode = $this->parsePhpCode($contents);
189            } catch (ParserErrorException $e) {
190                $this->logger->warning('Failed to parse namespace {namespaceName} in file {filePath} with error: {errorMessage}', [
191                    'namespaceName' => $namespaceName,
192                    'filePath' => $file->getSourcePath(),
193                    'errorMessage' => $e->getMessage(),
194                ]);
195                continue;
196            }
197
198            /** @var PHPClass[] $phpClasses */
199            $phpClasses = $phpCode->getClasses();
200            foreach ($phpClasses as $fqdnClassname => $class) {
201                // Skip classes defined in other files.
202                // I tried to use the $class->file property but it was autoloading from Strauss so incorrectly setting
203                // the path, different to the file being scanned.
204                // TODO this was causing false positives when found in comments.
205//                if (false !== strpos($contents, "use {$fqdnClassname};")) {
206//                    continue;
207//                }
208
209                $isAbstract = (bool) $class->is_abstract;
210                $extends     = $class->parentClass;
211                $interfaces  = $class->interfaces;
212                $classSymbol = $this->addDiscoveredClassChange($fqdnClassname, $isAbstract, $file, $extends, $namespaceSymbol, $interfaces);
213                if ($classSymbol) {
214                    $classSymbol->setDoRename($file->isDoPrefix());
215                }
216            }
217
218            /** @var PHPFunction[] $phpFunctions */
219            $phpFunctions = $phpCode->getFunctions();
220            foreach ($phpFunctions as $functionName => $function) {
221                if ($this->isBuiltInSymbol($functionName)) {
222                    continue;
223                }
224                $functionSymbol = $this->discoveredSymbols->getFunction($functionName);
225                if (is_null($functionSymbol)) {
226                    $functionSymbol = new FunctionSymbol($functionName, $file, $namespaceSymbol, $package);
227                    $this->add($functionSymbol);
228                }
229                $functionSymbol->addSourceFile($file);
230                $functionSymbol->setDoRename($file->isDoPrefix());
231            }
232
233            /** @var PHPConst[] $phpConstants */
234            $phpConstants = $phpCode->getConstants();
235            foreach ($phpConstants as $constantName => $constant) {
236                if (empty(trim($constantName, '\\'))) {
237                    continue;
238                }
239                $constantSymbol = $this->discoveredSymbols->getConst($constantName);
240                if (is_null($constantSymbol)) {
241                    $constantSymbol = new ConstantSymbol($constantName, $file, $namespaceSymbol);
242                    $this->add($constantSymbol, $file);
243                }
244                $constantSymbol->addSourceFile($file);
245                $constantSymbol->setDoRename($file->isDoPrefix());
246            }
247
248            $phpInterfaces = $phpCode->getInterfaces();
249            foreach ($phpInterfaces as $interfaceName => $interface) {
250                $interfaceSymbol = $this->discoveredSymbols->getInterface($interfaceName);
251                if (is_null($interfaceSymbol)) {
252                    $interfaceSymbol = new InterfaceSymbol($interfaceName, $file, $namespaceSymbol);
253                    $this->add($interfaceSymbol);
254                }
255                $interfaceSymbol->addSourceFile($file);
256                $interfaceSymbol->setDoRename($file->isDoPrefix());
257            }
258
259            $phpTraits = $phpCode->getTraits();
260            foreach ($phpTraits as $traitName => $trait) {
261                $traitSymbol = $this->discoveredSymbols->getTrait($traitName);
262                if (is_null($traitSymbol)) {
263                    $traitSymbol = new TraitSymbol($traitName, $file, $namespaceSymbol);
264                    $this->add($traitSymbol);
265                }
266                $traitSymbol->addSourceFile($file);
267                $traitSymbol->setDoRename($file->isDoPrefix());
268            }
269
270            // TODO: enum.
271        }
272    }
273
274    /**
275     * @param string $contents
276     * @return array<string,string>
277     */
278    protected function splitByNamespace(string $contents):array
279    {
280        $namespaceDeclarations = $this->getNamespaceDeclarations($contents);
281
282        if (count($namespaceDeclarations) === 0) {
283            if ($this->hasMalformedNamespaceDeclaration($contents)) {
284                return [];
285            }
286
287            return ['\\' => '<?php' . PHP_EOL . PHP_EOL . $contents];
288        }
289
290        if (count($namespaceDeclarations) === 1) {
291            $namespaceName = $namespaceDeclarations[0] ?? '\\';
292            $namespaceName = empty($namespaceName) ? '\\' : $namespaceName;
293            return [$namespaceName => $contents];
294        }
295
296        $result = [];
297        try {
298            $ast = $this->getParser()->parse(trim($contents)) ?? [];
299        } catch (\PhpParser\Error $e) {
300            $this->logger->error('Parse error: ' . $e->getMessage());
301            return [];
302        }
303
304        foreach ($ast as $rootNode) {
305            if ($rootNode instanceof Node\Stmt\Namespace_) {
306                if (is_null($rootNode->name)) {
307                    if (count($ast) === 1) {
308                        $result['\\'] = $contents;
309                    } else {
310                        $result['\\'] = '<?php' . PHP_EOL . PHP_EOL . $this->getPrettyPrinter()->prettyPrintFile($rootNode->stmts);
311                    }
312                } else {
313                    $namespaceName = $rootNode->name->name;
314                    if (count($ast) === 1) {
315                        $result[$namespaceName] = $contents;
316                    } else {
317                        // This was failing for `phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php`
318                        $result[$namespaceName] = '<?php' . PHP_EOL . PHP_EOL . 'namespace ' . $namespaceName . ';' . PHP_EOL . PHP_EOL . $this->getPrettyPrinter()->prettyPrintFile($rootNode->stmts);
319                    }
320                }
321            }
322        }
323
324        // TODO: is this necessary?
325        if (empty($result)) {
326            $result['\\'] = '<?php' . PHP_EOL . PHP_EOL . $contents;
327        }
328
329        return $result;
330    }
331
332    /**
333     * @param string $fqdnClassname
334     * @param bool $isAbstract
335     * @param FileBase $file
336     * @param ?string $extends
337     * @param NamespaceSymbol|null $namespace
338     * @param string[] $interfaces
339     */
340    protected function addDiscoveredClassChange(
341        string $fqdnClassname,
342        bool $isAbstract,
343        FileBase $file,
344        ?string $extends,
345        ?NamespaceSymbol $namespace,
346        array $interfaces
347    ): ?ClassSymbol {
348        // TODO: This should be included but marked not to prefix.
349        if ($this->isBuiltInSymbol($fqdnClassname)) {
350            $this->logger->debug('Skipping built-in symbol {symbolName}, possible a polyfill.', [
351                'symbolName' => $fqdnClassname,
352            ]);
353            return null;
354        }
355
356        $classSymbol = $this->discoveredSymbols->getClass($fqdnClassname);
357        if (is_null($classSymbol)) {
358            $classSymbol = new ClassSymbol($fqdnClassname, $file, $namespace, $isAbstract, $extends, $interfaces);
359            $this->add($classSymbol, $file);
360        }
361        $classSymbol->addSourceFile($file);
362        if ($file instanceof FileWithDependency) {
363            $file->addDiscoveredSymbol($classSymbol);
364        }
365        return $classSymbol;
366    }
367
368    protected function addDiscoveredNamespaceChange(string $fqdnNamespace, FileBase $file, ?ComposerPackage $package = null): NamespaceSymbol
369    {
370        $namespaceObj = $this->discoveredSymbols->getNamespace($fqdnNamespace);
371        if (is_null($namespaceObj)) {
372            $namespaceObj = new NamespaceSymbol($fqdnNamespace, $file);
373            $this->add($namespaceObj);
374        }
375        $namespaceObj->addSourceFile($file);
376        if ($file instanceof FileWithDependency) {
377            $file->addDiscoveredSymbol($namespaceObj);
378        }
379        return $namespaceObj;
380    }
381
382    /**
383     * Get a list of PHP built-in classes etc. so they are not prefixed.
384     *
385     * Polyfilled classes were being prefixed, but the polyfills are only active when the PHP version is below X,
386     * so calls to those prefixed polyfilled classnames would fail on newer PHP versions.
387     *
388     * NB: This list is not exhaustive. Any unloaded PHP extensions are not included.
389     *
390     * @see https://github.com/BrianHenryIE/strauss/issues/79
391     *
392     * ```
393     * array_filter(
394     *   get_declared_classes(),
395     *   function(string $className): bool {
396     *     $reflector = new \ReflectionClass($className);
397     *     return empty($reflector->getFileName());
398     *   }
399     * );
400     * ```
401     *
402     * @return string[]
403     */
404    protected function getBuiltIns(): array
405    {
406        if (empty($this->builtIns)) {
407            $this->loadBuiltIns();
408        }
409
410        return $this->builtIns;
411    }
412
413    /**
414     * Load the file containing the built-in PHP classes etc. and flatten to a single array of strings and store.
415     */
416    protected function loadBuiltIns(): void
417    {
418        $builtins = include __DIR__ . '/FileSymbol/builtinsymbols.php';
419
420        $flatArray = array();
421        array_walk_recursive(
422            $builtins,
423            function ($array) use (&$flatArray) {
424                if (is_array($array)) {
425                    $flatArray = array_merge($flatArray, array_values($array));
426                } else {
427                    $flatArray[] = $array;
428                }
429            }
430        );
431
432        $this->builtIns = $flatArray;
433        $this->builtInsLookup = array_fill_keys($this->builtIns, true);
434    }
435
436    protected function isBuiltInSymbol(string $symbolName): bool
437    {
438        if (empty($this->builtInsLookup)) {
439            $this->loadBuiltIns();
440        }
441
442        return isset($this->builtInsLookup[$symbolName]);
443    }
444
445    protected function getParser(): Parser
446    {
447        return $this->parser ??= (new ParserFactory())->createForNewestSupportedVersion();
448    }
449
450    protected function getPrettyPrinter(): Standard
451    {
452        return $this->prettyPrinter ??= new Standard();
453    }
454
455    protected function parsePhpCode(string $contents): ParserContainer
456    {
457        $parserContainer = new ParserContainer();
458        $visitor = new ASTVisitor($parserContainer);
459
460        $result = PhpCodeParser::process($contents, null, $parserContainer, $visitor);
461        if ($result instanceof ParserErrorHandler) {
462            throw new ParserErrorException($result);
463        }
464
465        $interfaces = $parserContainer->getInterfaces();
466        foreach ($interfaces as &$interface) {
467            $interface->parentInterfaces = $visitor->combineParentInterfaces($interface);
468        }
469        unset($interface);
470
471        $classes = &$parserContainer->getClassesByReference();
472        foreach ($classes as &$class) {
473            $class->interfaces = Utils::flattenArray(
474                $visitor->combineImplementedInterfaces($class),
475                false
476            );
477        }
478        unset($class);
479
480        return $parserContainer;
481    }
482
483    /**
484     * Collect declared namespaces in source order. Namespace operators (namespace\foo) are ignored.
485     *
486     * @return array<int,string|null> Null indicates the explicit global namespace.
487     */
488    protected function getNamespaceDeclarations(string $contents): array
489    {
490        $tokens = token_get_all($contents);
491
492        $declarations = [];
493        $tokenCount = count($tokens);
494        for ($i = 0; $i < $tokenCount; $i++) {
495            $token = $tokens[$i];
496
497            if (!is_array($token) || $token[0] !== T_NAMESPACE) {
498                continue;
499            }
500
501            $nextIndex = $this->nextSignificantTokenIndex($tokens, $i + 1);
502            if (is_null($nextIndex)) {
503                continue;
504            }
505
506            $next = $tokens[$nextIndex];
507            if (is_string($next) && ($next === ';' || $next === '{')) {
508                $declarations[] = null;
509                continue;
510            }
511
512            $namespaceName = '';
513            while (!is_null($nextIndex)) {
514                $current = $tokens[$nextIndex];
515
516                if (is_array($current) && $this->isNamespaceNameToken($current[0])) {
517                    $namespaceName .= $current[1];
518                    $nextIndex = $this->nextSignificantTokenIndex($tokens, $nextIndex + 1);
519                    continue;
520                }
521
522                if (is_string($current) && $current === '\\') {
523                    $namespaceName .= '\\';
524                    $nextIndex = $this->nextSignificantTokenIndex($tokens, $nextIndex + 1);
525                    continue;
526                }
527
528                if (is_string($current) && ($current === ';' || $current === '{')) {
529                    if ($namespaceName !== '') {
530                        $declarations[] = trim($namespaceName, '\\');
531                    }
532                }
533                break;
534            }
535        }
536
537        return $declarations;
538    }
539
540    protected function hasMalformedNamespaceDeclaration(string $contents): bool
541    {
542        $tokens = token_get_all($contents);
543        $tokenCount = count($tokens);
544        for ($i = 0; $i < $tokenCount; $i++) {
545            $token = $tokens[$i];
546
547            if (!is_array($token) || $token[0] !== T_NAMESPACE) {
548                continue;
549            }
550
551            $nextIndex = $this->nextSignificantTokenIndex($tokens, $i + 1);
552            if (is_null($nextIndex)) {
553                continue;
554            }
555
556            $next = $tokens[$nextIndex];
557            if (is_string($next) && ($next === ';' || $next === '{')) {
558                continue;
559            }
560
561            if (is_array($next) && $this->isNamespaceNameToken($next[0])) {
562                continue;
563            }
564
565            return true;
566        }
567
568        return false;
569    }
570
571    /**
572     * @param array<int, array<int, mixed>|string> $tokens
573     */
574    protected function nextSignificantTokenIndex(array $tokens, int $start): ?int
575    {
576        $tokenCount = count($tokens);
577        for ($i = $start; $i < $tokenCount; $i++) {
578            $token = $tokens[$i];
579            if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) {
580                continue;
581            }
582
583            return $i;
584        }
585
586        return null;
587    }
588
589    protected function isNamespaceNameToken(int $tokenType): bool
590    {
591        if ($tokenType === T_STRING || $tokenType === T_NS_SEPARATOR) {
592            return true;
593        }
594
595        return
596            (defined('T_NAME_QUALIFIED') && $tokenType === T_NAME_QUALIFIED)
597            || (defined('T_NAME_FULLY_QUALIFIED') && $tokenType === T_NAME_FULLY_QUALIFIED)
598            || (defined('T_NAME_RELATIVE') && $tokenType === T_NAME_RELATIVE);
599    }
600}