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