Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.80% covered (warning)
80.80%
101 / 125
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
DependenciesEnumerator
80.80% covered (warning)
80.80%
101 / 125
60.00% covered (warning)
60.00%
3 / 5
37.80
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
81.90% covered (warning)
81.90%
86 / 105
0.00% covered (danger)
0.00%
0 / 1
23.61
 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                $requiredComposerPackage = ComposerPackage::fromFile($packageComposerFile, $overrideAutoload);
150                $requiredComposerPackage->setRealpath(
151                    $this->filesystem->normalizePath(
152                        Platform::realpath(
153                            $this->filesystem->makeAbsolute($packageComposerFile)
154                        )
155                    )
156                );
157            } else {
158                // Some packages download with NO `composer.json`! E.g. woocommerce/action-scheduler.
159                // Some packages download to a different directory than the package name.
160                $this->logger->debug('Could not find ' . $requiredPackageName . '\'s composer.json in vendor dir, trying composer.lock: ' . $packageComposerFile);
161
162                // TODO: These (.json, .lock) should be read once and reused.
163                $composerJsonString = $this->filesystem->read($this->config->getProjectAbsolutePath() . '/' . Factory::getComposerFile());
164                /** @var ComposerJsonArray $composerJson */
165                $composerJson       = json_decode($composerJsonString, true, 512, JSON_THROW_ON_ERROR);
166
167                if (isset($composerJson['provide']) && in_array($requiredPackageName, array_keys($composerJson['provide']))) {
168                    $this->logger->info('Skipping ' . $requiredPackageName . ' as it is in the composer.json provide list');
169                    continue;
170                }
171
172                $composerLockPath = $this->config->getProjectAbsolutePath() . '/' . Factory::getLockFile(Factory::getComposerFile());
173                $composerLockString     = $this->filesystem->read($composerLockPath);
174                /** @var null|array{packages:array{name:string, type:string, requires?:array<string,string>, autoload?:AutoloadKeyArray}} $composerLockJsonArray */
175                $composerLockJsonArray           = json_decode($composerLockString, true);
176
177                if (is_null($composerLockJsonArray)) {
178                    continue;
179                }
180
181                /** @var ?ComposerJsonArray $requiredPackageComposerJson */
182                $requiredPackageComposerJson = null;
183                /** @var array{name:string, type:string, requires?:array<string,string>, autoload?:AutoloadKeyArray} $packageJson */
184                foreach ($composerLockJsonArray['packages'] as $packageJson) {
185                    if ($requiredPackageName === $packageJson['name']) {
186                        $requiredPackageComposerJson = $packageJson;
187                        break;
188                    }
189                }
190
191                if (is_null($requiredPackageComposerJson)) {
192                    // e.g. composer-plugin-api, composer-runtime-api
193                    $this->logger->info('Skipping ' . $requiredPackageName . ' as it is not in composer.lock');
194                    continue;
195                }
196
197                $this->logger->info('Found {requiredPackageName} in composer.lock.', [
198                    'requiredPackageName' => $requiredPackageName,
199                ]);
200
201                if (!isset($requiredPackageComposerJson['autoload'])
202                    && empty($requiredPackageComposerJson['require'])
203                    && (!isset($requiredPackageComposerJson['type']) || $requiredPackageComposerJson['type'] != 'metapackage')
204                    && ! $this->filesystem->directoryExists(dirname($packageComposerFile))
205                ) {
206                    // e.g. symfony/polyfill-php72 when installed on PHP 7.2 or later.
207                    $this->logger->info('Skipping ' . $requiredPackageName . ' as it is has no autoload key (possibly a polyfill unnecessary for this version of PHP).');
208                    continue;
209                }
210
211                $projectVendorAbsoluteDir = $this->config->getAbsoluteVendorDirectory();
212
213                $requiredComposerPackage = ComposerPackage::fromComposerJsonArray($requiredPackageComposerJson, $overrideAutoload);
214            }
215
216            if (isset($installedJsonPackages[$requiredPackageName])
217                && is_null($requiredComposerPackage->getRealPath())
218                && $installedJsonPackages[$requiredPackageName]['dist']['type'] === 'path') {
219                $installedPackage = $installedJsonPackages[$requiredPackageName];
220                $path = $installedPackage['dist']['url'];
221
222                $packageRealPath = $this->filesystem->normalizePath(
223                    Platform::realpath(
224                        $this->filesystem->makeAbsolute(
225                            $this->config->getProjectAbsolutePath() . '/'.  $path
226                        )
227                    )
228                );
229                $requiredComposerPackage->setRealpath(
230                    $packageRealPath
231                );
232            }
233            unset($installedPackage);
234            $requiredComposerPackage->setProjectVendorDirectory(
235                $this->filesystem->normalizePath(
236                    $this->config->getAbsoluteVendorDirectory()
237                )
238            );
239
240            $this->logger->info('Analysing package ' . $requiredComposerPackage->getPackageName());
241            $this->flatDependencyArray[$requiredComposerPackage->getPackageName()] = $requiredComposerPackage;
242
243            $nextRequiredPackageNames = $requiredComposerPackage->getRequiresNames();
244
245            if (0 !== count($nextRequiredPackageNames)) {
246                $packageRequiresString = $requiredComposerPackage->getPackageName() . ' requires packages: ';
247                $this->logger->debug($packageRequiresString . implode(', ', $nextRequiredPackageNames));
248            } else {
249                $this->logger->debug($requiredComposerPackage->getPackageName() . ' requires no packages.');
250                continue;
251            }
252
253            $newPackages = array_diff($nextRequiredPackageNames, array_keys($this->flatDependencyArray));
254
255            $newPackagesString = implode(', ', $newPackages);
256            if (!empty($newPackagesString)) {
257                $this->logger->debug(sprintf(
258                    'New packages: %s%s',
259                    str_repeat(' ', strlen($packageRequiresString) - strlen('New packages: ')),
260                    $newPackagesString
261                ));
262            } else {
263                $this->logger->debug('No new packages.');
264                continue;
265            }
266
267            $this->recursiveGetAllDependencies($newPackages);
268        }
269    }
270
271    /**
272     * Get the recorded files autoloaders.
273     *
274     * @return array<string, array<string>>
275     */
276    public function getAllFilesAutoloaders(): array
277    {
278        $filesAutoloaders = array();
279        foreach ($this->flatDependencyArray as $packageName => $composerPackage) {
280            if (isset($composerPackage->getAutoload()['files'])) {
281                $filesAutoloaders[$packageName] = $composerPackage->getAutoload()['files'];
282            }
283        }
284        return $filesAutoloaders;
285    }
286
287    /**
288     * Unset PHP, ext-*, ...
289     */
290    protected function removeVirtualPackagesFilter(string $requiredPackageName): bool
291    {
292        return ! (
293            ComposerPackage::isPlatformPackageName($requiredPackageName, true)
294            || in_array($requiredPackageName, $this->virtualPackages)
295        );
296    }
297}