Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.43% covered (warning)
63.43%
85 / 134
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
DumpAutoload
63.43% covered (warning)
63.43%
85 / 134
42.86% covered (danger)
42.86%
3 / 7
33.84
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 generatedPrefixedAutoloader
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 generatedMainAutoloader
93.22% covered (success)
93.22%
55 / 59
0.00% covered (danger)
0.00%
0 / 1
7.02
 isOptimizeAutoloaderEnabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 createInstalledVersionsFiles
78.26% covered (warning)
78.26%
18 / 23
0.00% covered (danger)
0.00%
0 / 1
3.09
 prefixNewAutoloader
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
6
 getSuffix
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace BrianHenryIE\Strauss\Pipeline\Autoload;
4
5use BrianHenryIE\Strauss\Composer\ComposerPackage;
6use BrianHenryIE\Strauss\Files\File;
7use BrianHenryIE\Strauss\Config\AutoloadConfigInterface;
8use BrianHenryIE\Strauss\Config\OptimizeAutoloaderConfigInterface;
9use BrianHenryIE\Strauss\Helpers\FileSystem;
10use BrianHenryIE\Strauss\Pipeline\FileEnumerator;
11use BrianHenryIE\Strauss\Pipeline\Prefixer;
12use BrianHenryIE\Strauss\Types\DiscoveredSymbols;
13use BrianHenryIE\Strauss\Types\NamespaceSymbol;
14use Composer\Autoload\AutoloadGenerator;
15use Composer\Config;
16use Composer\Factory;
17use Composer\IO\NullIO;
18use Composer\Json\JsonFile;
19use Composer\Repository\InstalledFilesystemRepository;
20use League\Flysystem\FilesystemException;
21use Psr\Log\LoggerAwareTrait;
22use Psr\Log\LoggerInterface;
23use Seld\JsonLint\ParsingException;
24
25/**
26 * @phpstan-import-type AutoloadKeyArray from ComposerPackage
27 * @phpstan-import-type ComposerConfigArray from ComposerPackage
28 * @phpstan-import-type ComposerJsonArray from ComposerPackage
29 */
30class DumpAutoload
31{
32    use LoggerAwareTrait;
33
34    protected AutoloadConfigInterface $config;
35
36    protected FileSystem $filesystem;
37
38    protected Prefixer $projectReplace;
39
40    protected FileEnumerator $fileEnumerator;
41    protected ComposerAutoloadGeneratorFactory $composerAutoloadGeneratorFactory;
42
43    public function __construct(
44        AutoloadConfigInterface $config,
45        Filesystem $filesystem,
46        LoggerInterface $logger,
47        Prefixer $projectReplace,
48        FileEnumerator $fileEnumerator,
49        ComposerAutoloadGeneratorFactory $composerAutoloadGeneratorFactory
50    ) {
51        $this->config = $config;
52        $this->filesystem = $filesystem;
53        $this->setLogger($logger);
54        $this->projectReplace = $projectReplace;
55        $this->fileEnumerator = $fileEnumerator;
56
57        $this->composerAutoloadGeneratorFactory = $composerAutoloadGeneratorFactory;
58    }
59
60    /**
61     * Create `autoload.php` and the `vendor-prefixed/composer` directory.
62     * @throws ParsingException
63     * @throws FilesystemException
64     */
65    public function generatedPrefixedAutoloader(): void
66    {
67        $this->generatedMainAutoloader();
68
69        $this->createInstalledVersionsFiles();
70
71        $this->prefixNewAutoloader();
72    }
73
74    /**
75     * Uses `vendor/composer/installed.json` to output autoload files to `vendor-prefixed/composer`.
76     *
77     * @throws ParsingException
78     * @throws FilesystemException
79     */
80    protected function generatedMainAutoloader(): void
81    {
82        /**
83         * Unfortunately, `::dump()` creates the target directories if they don't exist, even though it otherwise respects `::setDryRun()`.
84         *
85         * {@see https://github.com/composer/composer/pull/12396} might fix this.
86         */
87        if ($this->config->isDryRun()) {
88            return;
89        }
90
91        $defaultVendorDirBefore = Config::$defaultConfig['vendor-dir'];
92        Config::$defaultConfig['vendor-dir'] = $this->config->getRelativeTargetDirectory();
93
94        $projectComposerJson = new JsonFile(
95            $this->filesystem->makeAbsolute(
96                $this->filesystem->normalizePath(
97                    $this->config->getProjectAbsolutePath() . '/' . Factory::getComposerFile()
98                )
99            )
100        );
101
102        /** @var ComposerJsonArray $projectComposerJsonArray */
103        $projectComposerJsonArray = $projectComposerJson->read();
104        if (isset($projectComposerJsonArray['config'], $projectComposerJsonArray['config']['vendor-dir'])) {
105            $projectComposerJsonArray['config']['vendor-dir'] = $this->config->getRelativeTargetDirectory();
106        }
107
108        /**
109         * Loop over all packages that should be included and ensure the root package requires them. Composer only
110         * includes packages in the autoloader that are required by a parent package (including root). Without this,
111         * packages that are selectively prefixed are not included in the autoloader.
112         *
113         * @see AutoloadGenerator::filterPackageMap()
114         */
115        foreach ($this->config->getPackagesToPrefix() as $name => $package) {
116            $projectComposerJsonArray['require'][$name] = '*';
117        }
118
119        // Include the project root autoload in the vendor-prefixed autoloader?
120        if (isset($projectComposerJsonArray['autoload']) && !$this->config->isIncludeRootAutoload()) {
121            $projectComposerJsonArray['autoload'] = [];
122        }
123
124        $composer = Factory::create(new NullIO(), $projectComposerJsonArray);
125        $installationManager = $composer->getInstallationManager();
126        $package = $composer->getPackage();
127
128        /**
129         * Cannot use `$composer->getConfig()`, need to create a new one so the `vendor-dir` is correct.
130         */
131        $config = new Config(
132            false,
133            $this->filesystem->makeAbsolute(
134                $this->config->getProjectAbsolutePath()
135            )
136        );
137
138        /** @var array{config?: array<string, mixed>} $projectComposerConfigMergeArray */
139        $projectComposerConfigMergeArray = ['config' => $projectComposerJsonArray['config'] ?? []];
140
141        $config->merge($projectComposerConfigMergeArray);
142
143        $generator = $this->composerAutoloadGeneratorFactory->get(
144            $this->config->getNamespacePrefix() ?? $this->config->getProjectAbsolutePath(),
145            $composer->getEventDispatcher()
146        );
147        $isOptimize = $this->isOptimizeAutoloaderEnabled();
148        $generator->setDryRun($this->config->isDryRun());
149        $generator->setClassMapAuthoritative($isOptimize);
150        $generator->setRunScripts(false);
151//        $generator->setApcu($apcu, $apcuPrefix);
152//        $generator->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input));
153
154        $installedJsonFile = new JsonFile(
155            $this->filesystem->makeAbsolute($this->config->getAbsoluteTargetDirectory() . '/composer/installed.json')
156        );
157        /** @var array{dev?:bool} $installedJson */
158        $installedJson = $installedJsonFile->read();
159        $localRepo = new InstalledFilesystemRepository($installedJsonFile);
160
161        /**
162         * If the target directory is different to the vendor directory, then we do not want to include dev
163         * dependencies, but if it is vendor, then unless composer install was run with --no-dev, we do want them.
164         */
165        if (!$this->config->isTargetDirectoryVendor()) {
166            $isDevMode = false;
167        } else {
168            $isDevMode = (bool) ($installedJson['dev'] ?? false);
169        }
170        $generator->setDevMode($isDevMode);
171
172        $strictAmbiguous = false; // $input->getOption('strict-ambiguous')
173
174        // This will output the autoload_static.php etc. files to `vendor-prefixed/composer`.
175        $generator->dump(
176            $config,
177            $localRepo,
178            $package,
179            $installationManager,
180            'composer',
181            $isOptimize,
182            $this->getSuffix(),
183            $composer->getLocker(),
184            $strictAmbiguous
185        );
186
187        /**
188         * Tests fail if this is absent.
189         *
190         * Arguably this should be in ::setUp() and tearDown() in the test classes, but if other tools run after Strauss
191         * then they might expect it to be unmodified.
192         */
193        Config::$defaultConfig['vendor-dir'] = $defaultVendorDirBefore;
194    }
195
196    /**
197     * Keep backward compatibility with configs implementing only AutoloadConfigInterface.
198     */
199    protected function isOptimizeAutoloaderEnabled(): bool
200    {
201        return $this->config instanceof OptimizeAutoloaderConfigInterface
202            ? $this->config->isOptimizeAutoloader()
203            : true;
204    }
205
206    /**
207     * Create `InstalledVersions.php` and `installed.php`.
208     *
209     * This file is copied in all Composer installations.
210     * It is added always in `ComposerAutoloadGenerator::dump()`, called above.
211     * If the file does not exist, its entry in the classmap will not be prefixed and will cause autoloading issues for the real class.
212     *
213     * The accompanying `installed.php` is unique per install. Copy it and filter its packages to the packages that was copied.
214     * @throws FilesystemException
215     */
216    protected function createInstalledVersionsFiles(): void
217    {
218        if ($this->config->isTargetDirectoryVendor()) {
219            return;
220        }
221
222        $sourcePath = $this->config->getAbsoluteVendorDirectory() . '/composer/InstalledVersions.php';
223
224        if (!file_exists($sourcePath)) {
225            $this->logger->debug('InstalledVersions.php does not exist at {sourcePath}, skipping copy.', [
226                'sourcePath' => $sourcePath
227            ]);
228            return;
229        }
230
231        $this->filesystem->copy(
232            $sourcePath,
233            $this->config->getAbsoluteTargetDirectory() . '/composer/InstalledVersions.php'
234        );
235
236        // This is just `<?php return array(...);`
237        $installedPhpString = $this->filesystem->read($this->config->getAbsoluteVendorDirectory() . '/composer/installed.php');
238        $installed = eval(str_replace('<?php', '', $installedPhpString));
239
240        $targetPackages = $this->config->getPackagesToCopy();
241        $targetPackagesNames = array_keys($targetPackages);
242
243        $installed['versions'] = array_filter($installed['versions'], function ($packageName) use ($targetPackagesNames) {
244            return in_array($packageName, $targetPackagesNames);
245        }, ARRAY_FILTER_USE_KEY);
246
247        $installedArrayString = var_export($installed, true);
248
249        $newInstalledPhpString = "<?php return $installedArrayString;";
250
251        // Update `__DIR__` which was evaluated during the `include`/`eval`.
252        $newInstalledPhpString = preg_replace('/(\'install_path\' => )(.*)(\/\.\..*)/', "$1__DIR__ . '$3", $newInstalledPhpString) ?? $newInstalledPhpString;
253
254        $this->filesystem->write($this->config->getAbsoluteTargetDirectory() . '/composer/installed.php', $newInstalledPhpString);
255    }
256
257    /**
258     * @throws FilesystemException
259     */
260    protected function prefixNewAutoloader(): void
261    {
262        if ($this->config->isTargetDirectoryVendor()) {
263            return;
264        }
265
266        $this->logger->debug('Prefixing the new Composer autoloader.');
267
268        $projectFiles = $this->fileEnumerator->compileFileListForPaths([
269            $this->config->getAbsoluteTargetDirectory() . '/composer',
270        ]);
271
272        $phpFiles = array_filter(
273            $projectFiles->getFiles(),
274            fn($file) => $file->isPhpFile()
275        );
276
277        $phpFilesAbsolutePaths = array_map(
278            fn($file) => $file->getSourcePath(),
279            $phpFiles
280        );
281
282        $sourceFile = new File(__DIR__, __DIR__, __DIR__);
283        $composerAutoloadNamespaceSymbol = new NamespaceSymbol(
284            'Composer\\Autoload',
285            $sourceFile
286        );
287        $composerAutoloadNamespaceSymbol->setReplacement(
288            $this->config->getNamespacePrefix() . '\\Composer\\Autoload'
289        );
290        $composerNamespaceSymbol = new NamespaceSymbol(
291            'Composer',
292            $sourceFile
293        );
294        $composerNamespaceSymbol->setReplacement(
295            $this->config->getNamespacePrefix() . '\\Composer'
296        );
297
298        $discoveredSymbols = new DiscoveredSymbols();
299        $discoveredSymbols->add(
300            $composerNamespaceSymbol
301        );
302        $discoveredSymbols->add(
303            $composerAutoloadNamespaceSymbol
304        );
305
306        $this->projectReplace->replaceInProjectFiles($discoveredSymbols, $phpFilesAbsolutePaths);
307    }
308
309    /**
310     * If there is an existing autoloader, it will use the same suffix. If there is not, it pulls the suffix from
311     * {Composer::getLocker()} and clashes with the existing autoloader.
312     *
313     * @see https://github.com/composer/composer/blob/ae208dc1e182bd45d99fcecb956501da212454a1/src/Composer/Autoload/AutoloadGenerator.php#L429
314     * @see AutoloadGenerator::dump() 412:431
315     * @throws \Random\RandomException in PHP 8.2+
316     * @throws FilesystemException
317     */
318    protected function getSuffix(): ?string
319    {
320        return !$this->filesystem->fileExists($this->config->getAbsoluteTargetDirectory() . '/autoload.php')
321            ? bin2hex(random_bytes(16))
322            : null;
323    }
324}