Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 161
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
AutoloadedFilesEnumerator
0.00% covered (danger)
0.00%
0 / 161
0.00% covered (danger)
0.00%
0 / 6
1806
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 scanForAutoloadedFiles
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 scanPackage
0.00% covered (danger)
0.00%
0 / 91
0.00% covered (danger)
0.00%
0 / 1
306
 markIncludedFilesRecursive
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
90
 resolveIncludePath
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
110
 processClassmapFiles
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Use each package's autoload key to determine which files in the package are to be prefixed, apply exclusion rules.
4 */
5
6namespace BrianHenryIE\Strauss\Pipeline;
7
8use BrianHenryIE\Strauss\Composer\ComposerPackage;
9use BrianHenryIE\Strauss\Composer\DependenciesCollection;
10use BrianHenryIE\Strauss\Config\AutoloadFilesEnumeratorConfigInterface;
11use BrianHenryIE\Strauss\Helpers\Flysystem\FileSystem;
12use Composer\ClassMapGenerator\ClassMapGenerator;
13use League\Flysystem\FilesystemException;
14use PhpParser\NodeFinder;
15use PhpParser\ParserFactory;
16use Psr\Log\LoggerAwareTrait;
17use Psr\Log\LoggerInterface;
18use SplFileInfo;
19
20class AutoloadedFilesEnumerator
21{
22    use LoggerAwareTrait;
23
24    protected AutoloadFilesEnumeratorConfigInterface $config;
25    protected FileSystem $filesystem;
26
27    public function __construct(
28        AutoloadFilesEnumeratorConfigInterface $config,
29        FileSystem $filesystem,
30        LoggerInterface $logger
31    ) {
32        $this->config = $config;
33        $this->filesystem = $filesystem;
34        $this->setLogger($logger);
35    }
36
37    public function scanForAutoloadedFiles(DependenciesCollection $dependencies): void
38    {
39        foreach ($dependencies as $dependency) {
40            $this->scanPackage($dependency);
41        }
42    }
43
44    /**
45     * Read the autoload keys of the dependencies and marks the appropriate files to be prefixed
46     * @throws FilesystemException
47     */
48    protected function scanPackage(ComposerPackage $dependency): void
49    {
50        $this->logger->debug('AutoloadFileEnumerator::scanPackage() {packageName}', [ 'packageName' => $dependency->getPackageName() ]);
51
52        // Meta packages.
53        if (is_null($dependency->getPackageAbsolutePath())) {
54            return;
55        }
56
57        $this->logger->info("Scanning for autoloaded files in package {packageName}", [ 'packageName' => $dependency->getPackageName() ]);
58
59        $dependencyAutoloadKey = $dependency->getAutoload();
60        $excludeFromClassmap   = isset($dependencyAutoloadKey['exclude_from_classmap']) ? $dependencyAutoloadKey['exclude_from_classmap'] : [];
61
62        /**
63         * Where $dependency->autoload is ~
64         *
65         * [ "psr-4" => [ "BrianHenryIE\Strauss" => "src" ] ]
66         * Exclude "exclude-from-classmap"
67         * @see https://getcomposer.org/doc/04-schema.md#exclude-files-from-classmaps
68         */
69        $autoloaders = array_filter($dependencyAutoloadKey, function ($type) {
70            return 'exclude-from-classmap' !== $type;
71        }, ARRAY_FILTER_USE_KEY);
72
73        $dependencyPackageAbsolutePath   = $this->filesystem->makeAbsolute($dependency->getPackageAbsolutePath());
74        $fsDependencyPackageAbsolutePath = $this->filesystem->makeAbsolute($dependencyPackageAbsolutePath);
75
76        $excluded     = null;
77        $autoloadType = 'classmap';
78
79        // Used in Composer `ClassMapGenerator::scanPaths()`.
80        $excludedDirs = array_map(
81            fn(string $path) => $fsDependencyPackageAbsolutePath . '/' . $path,
82            $excludeFromClassmap
83        );
84
85        foreach ($autoloaders as $autoloaderType => $value) {
86            // Might have to switch/case here.
87
88            $classMapGenerator = new ClassMapGenerator();
89
90            /** @var ?string $namespace */
91            $namespace = null;
92
93            switch ($autoloaderType) {
94                case 'files':
95                    $filesAbsolutePaths   = array_map(
96                        fn(string $path) => $dependencyPackageAbsolutePath . '/' . $path,
97                        (array) $value
98                    );
99                    $filesAutoloaderFiles = $this->filesystem->findAllFilesAbsolutePaths($filesAbsolutePaths, true);
100                    foreach ($filesAutoloaderFiles as $filePackageAbsolutePath) {
101                        $filePackageRelativePath = $this->filesystem->getRelativePath(
102                            $dependencyPackageAbsolutePath,
103                            $filePackageAbsolutePath
104                        );
105                        $file                    = $dependency->getFile(FileSystem::normalizeDirSeparator($filePackageRelativePath));
106                        if (! $file) {
107                            $this->logger->warning("Expected discovered file at {relativePath} not found in package {packageName}", [
108                                'relativePath' => $filePackageRelativePath,
109                                'packageName'  => $dependency->getPackageName(),
110                            ]);
111                        } else {
112                            $file->addAutoloaderType('files');
113                            $file->setDoPrefix(true);
114                        }
115
116                        $visited = [];
117                        $this->markIncludedFilesRecursive($filePackageAbsolutePath, $dependency, $dependencyPackageAbsolutePath, $visited);
118                    }
119                    break;
120                case 'classmap':
121                    $autoloadKeyPaths = array_map(
122                        fn(string $path) => $dependencyPackageAbsolutePath . '/' . ltrim($path, '/'),
123                        (array) $value
124                    );
125                    foreach ($autoloadKeyPaths as $autoloadKeyPath) {
126                        if (! $this->filesystem->exists($autoloadKeyPath)) {
127                            $this->logger->warning(
128                                "Skipping non-existent autoload path in {packageName}: {path}",
129                                [ 'packageName' => $dependency->getPackageName(), 'path' => $autoloadKeyPath ]
130                            );
131                            continue;
132                        }
133                        $classMapGenerator->scanPaths(
134                            $this->filesystem->makeAbsolute($autoloadKeyPath),
135                            $excluded,
136                            $autoloadType,
137                            $namespace,
138                            $excludedDirs,
139                        );
140                    }
141                    $this->processClassmapFiles($classMapGenerator, $dependency, $autoloaderType);
142                    break;
143                case 'psr-0':
144                case 'psr-4':
145                    foreach ((array) $value as $namespace => $namespaceRelativePaths) {
146                        $psrPaths = array_map(
147                            fn(string $path) => $dependencyPackageAbsolutePath . '/' . ltrim($path, '/'),
148                            (array) $namespaceRelativePaths
149                        );
150
151                        foreach ($psrPaths as $autoloadKeyPath) {
152                            if (! $this->filesystem->exists($autoloadKeyPath)) {
153                                $this->logger->warning(
154                                    "Skipping non-existent autoload path in {packageName}: {path}",
155                                    [ 'packageName' => $dependency->getPackageName(), 'path' => $autoloadKeyPath ]
156                                );
157                                continue;
158                            }
159                            $absolutePath = $this->filesystem->makeAbsolute($autoloadKeyPath);
160                            if (str_starts_with($absolutePath, 'mem://')) {
161                                $absolutePath = new SplFileInfo($absolutePath);
162                            }
163                            $classMapGenerator->scanPaths(
164                                $absolutePath,
165                                $excluded,
166                                $autoloadType,
167                                $namespace,
168                                $excludedDirs,
169                            );
170                            $this->processClassmapFiles($classMapGenerator, $dependency, $autoloaderType);
171                        }
172                    }
173                    break;
174                default:
175                    $this->logger->warning('Unexpected autoloader type');
176                    // TODO: include everything;
177                    break;
178            }
179        }
180    }
181
182    /**
183     * @param string $absoluteFilePath
184     * @param ComposerPackage $dependency
185     * @param string $packageAbsolutePath
186     * @param array<string,bool> $visited
187     *
188     * @return void
189     * @throws FilesystemException
190     */
191    private function markIncludedFilesRecursive(
192        string $absoluteFilePath,
193        ComposerPackage $dependency,
194        string $packageAbsolutePath,
195        array &$visited
196    ): void {
197        if (isset($visited[$absoluteFilePath])) {
198            return;
199        }
200        $visited[$absoluteFilePath] = true;
201
202        if (!$this->filesystem->exists($absoluteFilePath)) {
203            return;
204        }
205
206        $contents = $this->filesystem->read($absoluteFilePath);
207        $parser = (new ParserFactory())->createForNewestSupportedVersion();
208        try {
209            $ast = $parser->parse($contents);
210        } catch (\PhpParser\Error $e) {
211            $this->logger->warning('Could not parse {file}: {message}', [
212                'file' => $absoluteFilePath, 'message' => $e->getMessage(),
213            ]);
214            return;
215        }
216
217        if (is_null($ast)) {
218            $this->logger->warning('Parsed {file} return null', [
219                'file' => $absoluteFilePath,
220            ]);
221            return;
222        }
223
224        $nodeFinder = new NodeFinder();
225        $includeNodes = $nodeFinder->findInstanceOf($ast, \PhpParser\Node\Expr\Include_::class);
226        $includingDir = dirname($absoluteFilePath);
227
228        foreach ($includeNodes as $node) {
229            $resolvedPath = $this->resolveIncludePath($node->expr, $includingDir);
230            if ($resolvedPath === null) {
231                $this->logger->debug('Cannot statically resolve include expression in {file}', [
232                    'file' => $absoluteFilePath,
233                ]);
234                continue;
235            }
236
237            if (!str_starts_with($resolvedPath, $packageAbsolutePath)) {
238                continue;
239            }
240
241            $relPath = $this->filesystem->getRelativePath($packageAbsolutePath, $resolvedPath);
242            $file = $dependency->getFile(FileSystem::normalizeDirSeparator($relPath));
243            if ($file) {
244                $file->addAutoloaderType('files');
245                $file->setDoPrefix(true);
246            }
247
248            $this->markIncludedFilesRecursive($resolvedPath, $dependency, $packageAbsolutePath, $visited);
249        }
250    }
251
252    private function resolveIncludePath(\PhpParser\Node\Expr $expr, string $includingDir): ?string
253    {
254        if ($expr instanceof \PhpParser\Node\Scalar\String_) {
255            $path = $expr->value;
256            return str_starts_with($path, '/') ? $path : $includingDir . '/' . $path;
257        }
258
259        if ($expr instanceof \PhpParser\Node\Expr\BinaryOp\Concat) {
260            $left = $expr->left;
261            $right = $expr->right;
262
263            $base = null;
264            if ($left instanceof \PhpParser\Node\Scalar\MagicConst\Dir) {
265                $base = $includingDir;
266            } elseif ($left instanceof \PhpParser\Node\Expr\FuncCall
267                && $left->name instanceof \PhpParser\Node\Name
268                && strtolower((string) $left->name) === 'dirname'
269            ) {
270                $base = $includingDir;
271            }
272
273            if ($base !== null && $right instanceof \PhpParser\Node\Scalar\String_) {
274                return $base . $right->value;
275            }
276        }
277
278        return null;
279    }
280
281    protected function processClassmapFiles(ClassMapGenerator $classMapGenerator, ComposerPackage $dependency, string $autoloaderType): void
282    {
283        $classMap = $classMapGenerator->getClassMap();
284        $classMapPaths = $classMap->getMap();
285        foreach ($classMapPaths as $fileAbsolutePath) {
286            /**
287             * This will never be null because we have been looking inside this path!
288             *
289             * @var string $packageAbsolutePath
290             */
291            $packageAbsolutePath = $dependency->getPackageAbsolutePath();
292            $relativePath = $this->filesystem->getRelativePath($packageAbsolutePath, $fileAbsolutePath);
293            $file = $dependency->getFile($relativePath);
294            if (!$file) {
295                $this->logger->warning("Expected discovered file at {relativePath} not found in package {packageName}", [
296                    'relativePath' => $relativePath,
297                    'packageName' => $dependency->getPackageName(),
298                ]);
299            } else {
300                /**
301                 * We are assuming at this point that we will rename all autoloaded PHP files. Rules will be applied later.
302                 *
303                 * @see MarkSymbolsForRenaming
304                 */
305                $file->setDoPrefix(true);
306                $file->addAutoloaderType($autoloaderType);
307            }
308        }
309    }
310}