Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.25% covered (warning)
81.25%
104 / 128
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
DependenciesEnumerator
81.25% covered (warning)
81.25%
104 / 128
60.00% covered (warning)
60.00%
3 / 5
37.33
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getAllDependencies
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 recursiveGetAllDependencies
82.41% covered (warning)
82.41%
89 / 108
0.00% covered (danger)
0.00%
0 / 1
23.40
 getAllFilesAutoloaders
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 removeVirtualPackagesFilter
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Build a list of ComposerPackage objects for all dependencies.
4 */
5
6namespace BrianHenryIE\Strauss\Pipeline;
7
8use BrianHenryIE\Strauss\Composer\ComposerPackage;
9use BrianHenryIE\Strauss\Composer\DeepDependenciesCollection;
10use BrianHenryIE\Strauss\Composer\Extra\StraussConfig;
11use BrianHenryIE\Strauss\Helpers\Flysystem\FileSystem;
12use BrianHenryIE\Strauss\Pipeline\Cleanup\InstalledJson;
13use Composer\Factory;
14use Composer\Util\Platform;
15use Exception;
16use JsonException;
17use League\Flysystem\FilesystemException;
18use Psr\Log\LoggerAwareTrait;
19use Psr\Log\LoggerInterface;
20use Psr\Log\NullLogger;
21
22/**
23 * @phpstan-import-type InstalledJsonArray from InstalledJson
24 * @phpstan-import-type ComposerJsonArray from ComposerPackage
25 * @phpstan-import-type AutoloadKeyArray from ComposerPackage
26 */
27class DependenciesEnumerator
28{
29    use LoggerAwareTrait;
30
31    /**
32     * @var string[]
33     */
34    protected array $requiredPackageNames;
35
36    protected FileSystem $filesystem;
37
38    /** @var string[]  */
39    protected array $virtualPackages = array(
40        'php-http/client-implementation'
41    );
42
43    /** @var array<string,ComposerPackage> */
44    protected array $flatDependencyArray = [];
45
46    /**
47     * Record the files autoloaders for later use in building our own autoloader.
48     *
49     * Package-name: [ dir1, file1, file2, ... ].
50     *
51     * @var array<string, string[]>
52     */
53    protected array $filesAutoloaders = [];
54
55    /**
56     * @var array{}|array<string, array{files?:array<string>,classmap?:array<string>,"psr-4":array<string|array<string>>}> $overrideAutoload
57     */
58    protected array $overrideAutoload = array();
59    protected StraussConfig $config;
60
61    /**
62     * Constructor.
63     */
64    public function __construct(
65        StraussConfig    $config,
66        FileSystem       $filesystem,
67        ?LoggerInterface $logger = null
68    ) {
69        $this->overrideAutoload = $config->getOverrideAutoload();
70        $this->requiredPackageNames = $config->getPackages();
71
72        $this->filesystem = $filesystem;
73        $this->config = $config;
74
75        $this->setLogger($logger ?? new NullLogger());
76    }
77
78    /**
79     * @throws Exception
80     * @throws FilesystemException
81     */
82    public function getAllDependencies(): DeepDependenciesCollection
83    {
84        $this->recursiveGetAllDependencies($this->requiredPackageNames);
85
86        foreach ($this->flatDependencyArray as $composerPackage) {
87            foreach ($composerPackage->getRequiresNames() as $requiresName) {
88                // The package would be missing if it is in `provides`.
89                if (isset($this->flatDependencyArray[$requiresName])) {
90                    $composerPackage->addDependency($this->flatDependencyArray[$requiresName]);
91                }
92            }
93        }
94
95        return new DeepDependenciesCollection($this->flatDependencyArray);
96    }
97
98    /**
99     * @param string[] $requiredPackageNames
100     * @throws FilesystemException
101     * @throws JsonException
102     * @throws Exception
103     */
104    protected function recursiveGetAllDependencies(array $requiredPackageNames): void
105    {
106        $requiredPackageNames = array_filter($requiredPackageNames, array( $this, 'removeVirtualPackagesFilter' ));
107
108        $installedJsonPath = $this->filesystem->makeAbsolute(
109            sprintf(
110                "%s/composer/installed.json",
111                $this->config->getAbsoluteVendorDirectory()
112            )
113        );
114        $installedJsonTxt = $this->filesystem->read($installedJsonPath);
115        /** @var InstalledJsonArray $installedJson */
116        $installedJson = json_decode($installedJsonTxt, true);
117        $installedJsonPackages = [];
118        foreach ($installedJson['packages'] as $package) {
119            $installedJsonPackages[$package['name']] = $package;
120        }
121
122        foreach ($requiredPackageNames as $requiredPackageName) {
123            // Avoid infinite recursion.
124            if (isset($this->flatDependencyArray[$requiredPackageName])) {
125                continue;
126            }
127
128            $packageComposerFile = sprintf(
129                '%s/%s/composer.json',
130                $this->config->getAbsoluteVendorDirectory(),
131                $requiredPackageName
132            );
133
134            /**
135             * 1. Remove `mem://`
136             * 2. Add `c:\` or `/`
137             * @see https://github.com/composer/composer/pull/12396
138             */
139            $packageComposerFile = $this->filesystem->normalizePath($packageComposerFile);
140            $packageComposerFile = $this->filesystem->makeAbsolute($packageComposerFile);
141
142            $overrideAutoload = $this->overrideAutoload[ $requiredPackageName ] ?? null;
143
144            if ($this->filesystem->fileExists($packageComposerFile)) {
145                $this->logger->debug('Loading ComposerPackage::fromFile {packageComposerFilePath}', [
146                    'packageComposerFilePath' => $packageComposerFile,
147                ]);
148
149                /**
150                 * TODO: Calling `::makeAbsolute()` should be unnecessary when Composer PR merged.
151                 *
152                 * @see https://github.com/composer/composer/pull/12396
153                 */
154                $requiredComposerPackage = ComposerPackage::fromFile(
155                    $this->filesystem->makeAbsolute($packageComposerFile),
156                    $overrideAutoload
157                );
158                $requiredComposerPackage->setRealpath(
159                    $this->filesystem->normalizePath(
160                        Platform::realpath(
161                            $this->filesystem->makeAbsolute($packageComposerFile)
162                        )
163                    )
164                );
165            } else {
166                // Some packages download with NO `composer.json`! E.g. woocommerce/action-scheduler.
167                // Some packages download to a different directory than the package name.
168                $this->logger->debug('Could not find ' . $requiredPackageName . '\'s composer.json in vendor dir, trying composer.lock: ' . $packageComposerFile);
169
170                // TODO: These (.json, .lock) should be read once and reused.
171                $composerJsonString = $this->filesystem->read($this->config->getProjectAbsolutePath() . '/' . Factory::getComposerFile());
172                /** @var ComposerJsonArray $composerJson */
173                $composerJson       = json_decode($composerJsonString, true, 512, JSON_THROW_ON_ERROR);
174
175                if (isset($composerJson['provide']) && in_array($requiredPackageName, array_keys($composerJson['provide']))) {
176                    $this->logger->info('Skipping ' . $requiredPackageName . ' as it is in the composer.json provide list');
177                    continue;
178                }
179
180                $composerLockPath = $this->config->getProjectAbsolutePath() . '/' . Factory::getLockFile(Factory::getComposerFile());
181                $composerLockString     = $this->filesystem->read($composerLockPath);
182                /** @var null|array{packages:array{name:string, type:string, requires?:array<string,string>, autoload?:AutoloadKeyArray}} $composerLockJsonArray */
183                $composerLockJsonArray           = json_decode($composerLockString, true);
184
185                if (is_null($composerLockJsonArray)) {
186                    continue;
187                }
188
189                /** @var ?ComposerJsonArray $requiredPackageComposerJson */
190                $requiredPackageComposerJson = null;
191                /** @var array{name:string, type:string, requires?:array<string,string>, autoload?:AutoloadKeyArray} $packageJson */
192                foreach ($composerLockJsonArray['packages'] as $packageJson) {
193                    if ($requiredPackageName === $packageJson['name']) {
194                        $requiredPackageComposerJson = $packageJson;
195                        break;
196                    }
197                }
198
199                if (is_null($requiredPackageComposerJson)) {
200                    // e.g. composer-plugin-api, composer-runtime-api
201                    $this->logger->info('Skipping ' . $requiredPackageName . ' as it is not in composer.lock');
202                    continue;
203                }
204
205                $this->logger->info('Found {requiredPackageName} in composer.lock.', [
206                    'requiredPackageName' => $requiredPackageName,
207                ]);
208
209                if (!isset($requiredPackageComposerJson['autoload'])
210                    && empty($requiredPackageComposerJson['require'])
211                    && (!isset($requiredPackageComposerJson['type']) || $requiredPackageComposerJson['type'] != 'metapackage')
212                    && ! $this->filesystem->directoryExists(dirname($packageComposerFile))
213                ) {
214                    // e.g. symfony/polyfill-php72 when installed on PHP 7.2 or later.
215                    $this->logger->info('Skipping ' . $requiredPackageName . ' as it is has no autoload key (possibly a polyfill unnecessary for this version of PHP).');
216                    continue;
217                }
218
219                $projectVendorAbsoluteDir = $this->config->getAbsoluteVendorDirectory();
220
221                $requiredComposerPackage = ComposerPackage::fromComposerJsonArray($requiredPackageComposerJson, $overrideAutoload);
222            }
223
224            if (isset($installedJsonPackages[$requiredPackageName])
225                && is_null($requiredComposerPackage->getRealPath())
226                && $installedJsonPackages[$requiredPackageName]['dist']['type'] === 'path') {
227                $installedPackage = $installedJsonPackages[$requiredPackageName];
228                $path = $installedPackage['dist']['url'];
229
230                $packageRealPath = $this->filesystem->normalizePath(
231                    Platform::realpath(
232                        $this->filesystem->makeAbsolute(
233                            $this->config->getProjectAbsolutePath() . '/'.  $path
234                        )
235                    )
236                );
237                $requiredComposerPackage->setRealpath(
238                    $packageRealPath
239                );
240            }
241            unset($installedPackage);
242            $requiredComposerPackage->setProjectVendorDirectory(
243                $this->filesystem->normalizePath(
244                    $this->config->getAbsoluteVendorDirectory()
245                )
246            );
247
248            $this->logger->info('Analysing package ' . $requiredComposerPackage->getPackageName());
249            $this->flatDependencyArray[$requiredComposerPackage->getPackageName()] = $requiredComposerPackage;
250
251            $nextRequiredPackageNames = $requiredComposerPackage->getRequiresNames();
252
253            if (0 !== count($nextRequiredPackageNames)) {
254                $packageRequiresString = $requiredComposerPackage->getPackageName() . ' requires packages: ';
255                $this->logger->debug($packageRequiresString . implode(', ', $nextRequiredPackageNames));
256            } else {
257                $this->logger->debug($requiredComposerPackage->getPackageName() . ' requires no packages.');
258                continue;
259            }
260
261            $newPackages = array_diff($nextRequiredPackageNames, array_keys($this->flatDependencyArray));
262
263            $newPackagesString = implode(', ', $newPackages);
264            if (!empty($newPackagesString)) {
265                $this->logger->debug(sprintf(
266                    'New packages: %s%s',
267                    str_repeat(' ', strlen($packageRequiresString) - strlen('New packages: ')),
268                    $newPackagesString
269                ));
270            } else {
271                $this->logger->debug('No new packages.');
272                continue;
273            }
274
275            $this->recursiveGetAllDependencies($newPackages);
276        }
277    }
278
279    /**
280     * Get the recorded files autoloaders.
281     *
282     * @return array<string, array<string>>
283     */
284    public function getAllFilesAutoloaders(): array
285    {
286        $filesAutoloaders = array();
287        foreach ($this->flatDependencyArray as $packageName => $composerPackage) {
288            if (isset($composerPackage->getAutoload()['files'])) {
289                $filesAutoloaders[$packageName] = $composerPackage->getAutoload()['files'];
290            }
291        }
292        return $filesAutoloaders;
293    }
294
295    /**
296     * Unset PHP, ext-*, ...
297     */
298    protected function removeVirtualPackagesFilter(string $requiredPackageName): bool
299    {
300        return ! (
301            ComposerPackage::isPlatformPackageName($requiredPackageName, true)
302            || in_array($requiredPackageName, $this->virtualPackages)
303        );
304    }
305}