Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
22.42% covered (danger)
22.42%
74 / 330
4.55% covered (danger)
4.55%
1 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
DependenciesCommand
22.42% covered (danger)
22.42%
74 / 330
4.55% covered (danger)
4.55%
1 / 22
1265.28
0.00% covered (danger)
0.00%
0 / 1
 configure
98.21% covered (success)
98.21%
55 / 56
0.00% covered (danger)
0.00%
0 / 1
3
 execute
17.65% covered (danger)
17.65%
6 / 34
0.00% covered (danger)
0.00%
0 / 1
4.23
 loadProjectComposerPackage
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 loadConfigFromComposerJson
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 updateConfigFromCli
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 buildDependencyList
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
72
 enumerateFiles
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 enumeratePsrNamespaces
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 enumerateAutoloadedFiles
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 scanFilesForSymbols
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 markSymbolsForRenaming
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 determineChanges
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 markFilesExcludedFromChanges
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 analyseFilesToCopy
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 copyFiles
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
42
 performReplacements
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 performReplacementsInProjectFiles
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 addLicenses
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 generateAutoloader
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 generateAliasesFile
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 prefixComposerAutoloadFiles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cleanUp
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace BrianHenryIE\Strauss\Console\Commands;
4
5use BrianHenryIE\Strauss\Composer\ComposerPackage;
6use BrianHenryIE\Strauss\Composer\DeepDependenciesCollection;
7use BrianHenryIE\Strauss\Composer\DependenciesCollection;
8use BrianHenryIE\Strauss\Composer\ProjectComposerPackage;
9use BrianHenryIE\Strauss\Files\DiscoveredFiles;
10use BrianHenryIE\Strauss\Files\File;
11use BrianHenryIE\Strauss\Files\FileWithDependency;
12use BrianHenryIE\Strauss\Pipeline\Aliases\Aliases;
13use BrianHenryIE\Strauss\Pipeline\Autoload;
14use BrianHenryIE\Strauss\Pipeline\Autoload\Psr0;
15use BrianHenryIE\Strauss\Pipeline\Autoload\VendorComposerAutoload;
16use BrianHenryIE\Strauss\Pipeline\AutoloadedFilesEnumerator;
17use BrianHenryIE\Strauss\Pipeline\ChangeEnumerator;
18use BrianHenryIE\Strauss\Pipeline\Cleanup\Cleanup;
19use BrianHenryIE\Strauss\Pipeline\Cleanup\InstalledJson;
20use BrianHenryIE\Strauss\Pipeline\Copier;
21use BrianHenryIE\Strauss\Pipeline\DependenciesEnumerator;
22use BrianHenryIE\Strauss\Pipeline\FileCopyScanner;
23use BrianHenryIE\Strauss\Pipeline\FileEnumerator;
24use BrianHenryIE\Strauss\Pipeline\FileSymbolScanner;
25use BrianHenryIE\Strauss\Pipeline\Licenser;
26use BrianHenryIE\Strauss\Pipeline\MarkFilesExcludedFromChanges;
27use BrianHenryIE\Strauss\Pipeline\MarkSymbolsForRenaming;
28use BrianHenryIE\Strauss\Pipeline\Prefixer;
29use BrianHenryIE\Strauss\Types\DiscoveredSymbols;
30use BrianHenryIE\Strauss\Types\NamespaceSymbol;
31use BrianHenryIE\Strauss\Types\Psr0NamespaceSymbol;
32use Composer\Factory;
33use Exception;
34use Symfony\Component\Console\Command\Command;
35use Symfony\Component\Console\Input\InputArgument;
36use Symfony\Component\Console\Input\InputInterface;
37use Symfony\Component\Console\Output\OutputInterface;
38
39class DependenciesCommand extends AbstractRenamespacerCommand
40{
41    /** @var Prefixer */
42    protected Prefixer $replacer;
43
44    protected DependenciesEnumerator $dependenciesEnumerator;
45
46    /**
47     * ArrayAccess of \BrianHenryIE\Strauss\File objects indexed by their path relative to the output target directory.
48     *
49     * Each object contains the file's relative and absolute paths, the package and autoloaders it came from,
50     * and flags indicating should it / has it been copied / deleted etc.
51     *
52     */
53    protected DiscoveredFiles $discoveredFiles;
54    protected DiscoveredSymbols $discoveredSymbols;
55
56    /**
57     * Set name and description, add CLI arguments, call parent class to add dry-run, verbosity options.
58     *
59     * @used-by \Symfony\Component\Console\Command\Command::__construct
60     * @override {@see \Symfony\Component\Console\Command\Command::configure()} empty method.
61     *
62     * @return void
63     */
64    protected function configure()
65    {
66        $this->setName('dependencies');
67        $this->setDescription("Copy composer's `require` and prefix their namespace and classnames.");
68        $this->setHelp('');
69
70        $this->addOption(
71            'updateCallSites',
72            null,
73            InputArgument::OPTIONAL,
74            'Should replacements also be performed in project files? true|list,of,paths|false'
75        );
76
77        $this->addOption(
78            'deleteVendorPackages',
79            null,
80            4,
81            'Should original packages be deleted after copying? true|false',
82            false
83        );
84        // Is there a nicer way to add aliases?
85        $this->addOption(
86            'delete_vendor_packages',
87            null,
88            4,
89            '',
90            false
91        );
92
93        $this->addOption(
94            'dry-run',
95            null,
96            4,
97            'Do not actually make any changes',
98            false
99        );
100
101        $this->addOption(
102            'info',
103            null,
104            4,
105            'output level',
106            false
107        );
108
109        $this->addOption(
110            'debug',
111            null,
112            4,
113            'output level',
114            false
115        );
116
117        /**
118         * When run via. `strauss.phar`, classes such as `InstalledVersions` are prefixed, but when installed
119         * via Composer, the unprefixed version is used.
120         *
121         * TODO: deduplicate code with {@see AbstractRenamespacerCommand}.
122         */
123        $symfonyVersion = class_exists(\BrianHenryIE\Strauss\Composer\InstalledVersions::class)
124            ? \BrianHenryIE\Strauss\Composer\InstalledVersions::getVersion('symfony/console')
125            : \Composer\InstalledVersions::getVersion('symfony/console');
126
127        if (version_compare($symfonyVersion, '7.2', '<')) {
128            $this->addOption(
129                'silent',
130                's',
131                4,
132                'output level',
133                false
134            );
135        }
136
137        parent::configure();
138    }
139
140    /**
141     * @param InputInterface $input
142     * @param OutputInterface $output
143     *
144     * @return int
145     * @see Command::execute()
146     *
147     */
148    protected function execute(InputInterface $input, OutputInterface $output): int
149    {
150//        $this->setLogger($this->getIOLogger($input, $output));
151
152        try {
153            $this->logger->notice('Starting... '/** version */); // + PHP version
154
155            $this->loadProjectComposerPackage();
156            $this->loadConfigFromComposerJson();
157            $this->updateConfigFromCli($input);
158
159            // Checks dry-run, replaces filesystem and logger.
160            parent::execute($input, $output);
161
162            $this->buildDependencyList();
163
164            $this->enumerateFiles();
165
166            $this->discoveredSymbols = new DiscoveredSymbols();
167
168            $this->enumeratePsrNamespaces();
169            $this->enumerateAutoloadedFiles();
170            $this->scanFilesForSymbols();
171            $this->analyseFilesToCopy();
172            $this->markSymbolsForRenaming();
173            $this->determineChanges();
174            $this->markFilesExcludedFromChanges();
175
176            (new Psr0($this->filesystem, $this->logger))->setTargetDirectory(
177                $this->flatDependencyTree,
178                $this->discoveredFiles,
179                $this->discoveredSymbols
180            );
181
182            $this->copyFiles();
183
184            $this->performReplacements();
185
186            $this->performReplacementsInProjectFiles();
187
188            $this->addLicenses();
189
190            $this->cleanUp();
191
192            $this->generateAutoloader();
193
194            // After files have been deleted, we may need aliases.
195            $this->generateAliasesFile();
196
197            $this->prefixComposerAutoloadFiles();
198
199            $this->logger->notice('Done');
200        } catch (Exception $e) {
201            $this->logger->error($e->getMessage() . ' in ' . $e->getFile() . ' ' . $e->getLine());
202            $this->logger->error('Please submit a bug report with a minimally reproducing composer.json and logs from running strauss --debug');
203            return Command::FAILURE;
204        }
205
206        return Command::SUCCESS;
207    }
208
209    /**
210     * Load the project's composer package using the current working directory.
211     *
212     * @throws Exception
213     */
214    protected function loadProjectComposerPackage(): void
215    {
216        $this->logger->notice('Loading package...');
217
218        $composerFilePath = $this->filesystem->makeAbsolute(
219            $this->filesystem->normalizePath(
220                $this->workingDir . '/' .Factory::getComposerFile()
221            )
222        );
223        $defaultComposerFilePath = $this->filesystem->makeAbsolute($this->workingDir . '/composer.json');
224        if ($composerFilePath !== $defaultComposerFilePath) {
225            $this->logger->info('Using: ' . $composerFilePath);
226        }
227
228        $composerFilePath = $this->filesystem->normalizePath($composerFilePath);
229        $this->projectComposerPackage = new ProjectComposerPackage(
230            $this->filesystem->makeAbsolute($composerFilePath)
231        );
232
233        // TODO: Print the config that Strauss is using.
234        // Maybe even highlight what is default config and what is custom config.
235    }
236
237    /**
238     * Load Strauss config from the project's composer.json.
239     */
240    protected function loadConfigFromComposerJson(): void
241    {
242        $this->logger->notice('Loading composer.json config...');
243
244        $this->config = $this->projectComposerPackage->getStraussConfig();
245    }
246
247    protected function updateConfigFromCli(InputInterface $input): void
248    {
249        $this->logger->notice('Loading cli config...');
250
251        $this->config->updateFromCli($input);
252    }
253
254    /**
255     * 2. Built flat list of packages and dependencies.
256     *
257     * 2.1 Initiate getting dependencies for the project composer.json.
258     *
259     * @see DependenciesCommand::flatDependencyTree
260     */
261    protected function buildDependencyList(): void
262    {
263        $this->logger->notice('Building dependency list...');
264
265        $this->dependenciesEnumerator = new DependenciesEnumerator(
266            $this->config,
267            $this->filesystem,
268            $this->logger
269        );
270        $this->flatDependencyTree = $this->dependenciesEnumerator->getAllDependencies();
271
272        $this->config->setPackagesToCopy(
273            array_filter(
274                $this->flatDependencyTree->toArray(),
275                function ($dependency) {
276                    return !in_array($dependency, $this->config->getExcludePackagesFromCopy());
277                },
278                ARRAY_FILTER_USE_KEY
279            )
280        );
281
282        $this->config->setPackagesToPrefix(
283            array_filter($this->flatDependencyTree->toArray(), function ($dependency) {
284                return !in_array($dependency, $this->config->getExcludePackagesFromPrefixing());
285            },
286            ARRAY_FILTER_USE_KEY)
287        );
288
289        foreach ($this->flatDependencyTree->toArray() as $dependency) {
290            // Sort of duplicating the logic above.
291            $dependency->setCopy(
292                !in_array($dependency->getPackageName(), $this->config->getExcludePackagesFromCopy())
293            );
294
295            if ($this->config->isDeleteVendorPackages()) {
296                $dependency->setDelete(true);
297            }
298        }
299
300        // TODO: Print the dependency tree that Strauss has determined.
301
302        $symlinkedDependencies = array_filter(
303            $this->flatDependencyTree->toArray(),
304            fn ($dependency) => !is_null($dependency->getRealPath()) && !str_starts_with($dependency->getRealPath(), $this->config->getProjectAbsolutePath())
305        );
306
307        if (!empty($symlinkedDependencies) &&
308            ($this->config->isDeleteVendorFiles() || ($this->config->getAbsoluteTargetDirectory() === $this->config->getAbsoluteVendorDirectory()))
309        ) {
310            $list = implode(
311                ', ',
312                array_map(
313                    fn($dependency) => $dependency->getPackageName(),
314                    $symlinkedDependencies
315                )
316            );
317            $this->logger->error(
318                sprintf(
319                    'Symlinked package%s detected: %s',
320                    count($symlinkedDependencies) > 1 ? 's' : '',
321                    $list
322                )
323            );
324            // https://stackoverflow.com/a/65009324/336146
325            $this->logger->notice('Use `COMPOSER_MIRROR_PATH_REPOS=1 composer install` to copy symlinked packages to vendor directory.');
326            throw new Exception();
327        }
328    }
329
330
331    protected function enumerateFiles(): void
332    {
333        $this->logger->notice('Enumerating files...');
334
335        $fileEnumerator = new FileEnumerator(
336            $this->config,
337            $this->filesystem,
338            $this->logger
339        );
340
341        $this->discoveredFiles = $fileEnumerator->compileFileListForDependencies($this->flatDependencyTree);
342    }
343
344    protected function enumeratePsrNamespaces(): void
345    {
346        foreach ($this->config->getPackagesToPrefix() as $package) {
347            $autoloadTypes = $package->getAutoload();
348            foreach (array_keys($autoloadTypes) as $autoloadKeyType) {
349                switch ($autoloadKeyType) {
350                    case 'psr-0':
351                        // Fall-through.
352                    case 'psr-4':
353                        $namespaces = array_keys($autoloadTypes[$autoloadKeyType]);
354
355                        foreach ($namespaces as $namespace) {
356                            // TODO: log.
357
358                            $symbol = $autoloadKeyType === 'psr-0'
359                                ? new Psr0NamespaceSymbol(trim($namespace, '\\'))
360                                : new NamespaceSymbol(trim($namespace, '\\'));
361
362                            $this->discoveredSymbols->add($symbol);
363                            $symbol->addDependency($package);
364                            $package->addDiscoveredSymbol($symbol);
365                        }
366                        break;
367                    default:
368                        break;
369                }
370            }
371        }
372    }
373
374    protected function enumerateAutoloadedFiles(): void
375    {
376        $this->logger->notice('Enumerating autoload files...');
377
378        $autoloadFilesEnumerator = new AutoloadedFilesEnumerator(
379            $this->config,
380            $this->filesystem,
381            $this->logger
382        );
383        $autoloadFilesEnumerator->scanForAutoloadedFiles($this->flatDependencyTree);
384    }
385
386    protected function scanFilesForSymbols(): void
387    {
388        $this->logger->notice('Scanning files...');
389
390        $fileSymbolScanner = new FileSymbolScanner(
391            $this->config,
392            $this->discoveredSymbols,
393            $this->filesystem,
394            $this->logger
395        );
396
397        $fileSymbolScanner->findInFiles($this->discoveredFiles);
398    }
399
400    protected function markSymbolsForRenaming(): void
401    {
402
403        $markSymbolsForRenaming = new MarkSymbolsForRenaming(
404            $this->config,
405            $this->filesystem,
406            $this->logger
407        );
408
409        $markSymbolsForRenaming->scanSymbols($this->discoveredSymbols);
410    }
411
412    protected function determineChanges(): void
413    {
414        $this->logger->notice('Determining changes...');
415
416        $changeEnumerator = new ChangeEnumerator(
417            $this->config,
418            $this->logger
419        );
420        $changeEnumerator->determineReplacements($this->discoveredSymbols);
421    }
422
423    protected function markFilesExcludedFromChanges(): void
424    {
425        $this->logger->notice('Scanning files to omit from changes...');
426
427        $markFilesExcludedFromChanges = new MarkFilesExcludedFromChanges(
428            $this->config,
429            $this->logger
430        );
431
432        $markFilesExcludedFromChanges->scanDiscoveredFiles($this->discoveredFiles);
433    }
434
435    protected function analyseFilesToCopy(): void
436    {
437        (new FileCopyScanner($this->config, $this->filesystem, $this->logger))->scanFiles($this->discoveredFiles);
438    }
439
440    protected function copyFiles(): void
441    {
442
443        if ($this->config->isTargetDirectoryVendor()) {
444            // PSR-0 files need to be moved.
445            foreach ($this->discoveredFiles->getPsr0() as $file) {
446                if ($file->getSourcePath() === $file->getTargetAbsolutePath()) {
447                    continue;
448                }
449                $this->filesystem->copy( // TODO: change to MOVE
450                    $file->getSourcePath(),
451                    $file->getTargetAbsolutePath()
452                );
453            }
454
455            return;
456        }
457
458        $this->logger->notice('Copying files...');
459
460        $copier = new Copier(
461            $this->discoveredFiles,
462            $this->config,
463            $this->filesystem,
464            $this->logger
465        );
466
467
468        $copier->prepareTarget();
469        $copier->copy();
470
471        foreach ($this->flatDependencyTree as $package) {
472            if ($package->isCopy()) {
473                $package->setDidCopy(true);
474            }
475        }
476
477        $installedJson = new InstalledJson(
478            $this->config,
479            $this->filesystem,
480            $this->logger
481        );
482        $installedJson->copyInstalledJson();
483    }
484
485
486    // 5. Update namespaces and class names.
487    // Replace references to updated namespaces and classnames throughout the dependencies.
488    protected function performReplacements(): void
489    {
490        $this->logger->notice('Performing replacements...');
491
492        $this->replacer = new Prefixer(
493            $this->config,
494            $this->filesystem,
495            $this->logger
496        );
497
498        $this->replacer->replaceInFiles(
499            $this->discoveredSymbols,
500            $this->discoveredFiles->getFiles()
501        );
502    }
503
504    /**
505     * Update a project's /src/* files where they call the newly renamed /vendor/* classes etc.
506     */
507    protected function performReplacementsInProjectFiles(): void
508    {
509        // TODO: this doesn't do tests?!
510        $relativeCallSitePaths =
511            $this->config->getUpdateCallSites()
512            ?? $this->projectComposerPackage->getFlatAutoloadKey();
513
514        if (empty($relativeCallSitePaths)) {
515            return;
516        }
517
518        $callSitePaths = array_map(
519            fn($path) => $this->workingDir . '/' . $path,
520            $relativeCallSitePaths
521        );
522
523        $projectReplace = new Prefixer(
524            $this->config,
525            $this->filesystem,
526            $this->logger
527        );
528
529        $fileEnumerator = new FileEnumerator(
530            $this->config,
531            $this->filesystem,
532            $this->logger
533        );
534
535        $projectFiles = $fileEnumerator->compileFileListForPaths($callSitePaths);
536
537        // TODO: Warn when a file that was specified is not found
538        // $this->logger->warning('Expected file not found from project autoload: ' . $absolutePath);
539
540        $projectReplace->replaceInProjectFiles($this->discoveredSymbols, $projectFiles);
541    }
542
543    protected function addLicenses(): void
544    {
545        $this->logger->notice('Adding licenses...');
546
547        $author = $this->projectComposerPackage->getAuthor();
548
549        $dependencies = $this->flatDependencyTree;
550
551        $licenser = new Licenser(
552            $this->config,
553            $dependencies,
554            $author,
555            $this->filesystem,
556            $this->logger
557        );
558
559        $licenser->copyLicenses();
560
561        $modifiedFiles = $this->replacer->getModifiedFiles();
562        $licenser->addInformationToUpdatedFiles($modifiedFiles);
563    }
564
565    /**
566     * 6. Generate autoloader.
567     */
568    protected function generateAutoloader(): void
569    {
570        if (isset($this->projectComposerPackage->getAutoload()['classmap'])
571            && in_array(
572                $this->config->getAbsoluteTargetDirectory(),
573                array_map(
574                    fn(string $entry) => trim($entry, '\\/'),
575                    $this->projectComposerPackage->getAutoload()['classmap']
576                ),
577                true
578            )
579        ) {
580            $this->logger->notice('Skipping autoloader generation as target directory is in Composer classmap. Run `composer dump-autoload`.');
581            return;
582        }
583
584        $this->logger->notice('Generating autoloader...');
585
586        $autoload = new Autoload(
587            $this->config,
588            [],
589            $this->filesystem,
590            $this->logger
591        );
592
593        $autoload->generate($this->flatDependencyTree, $this->discoveredSymbols);
594    }
595
596    /**
597     * When namespaces are prefixed which are used by both require and require-dev dependencies,
598     * the require-dev dependencies need class aliases specified to point to the new class names/namespaces.
599     */
600    protected function generateAliasesFile(): void
601    {
602        if (!$this->config->isCreateAliases()) {
603            return;
604        }
605
606        $this->logger->notice('Generating aliases file...');
607
608        $aliases = new Aliases(
609            $this->config,
610            $this->filesystem,
611            $this->logger
612        );
613        $aliases->writeAliasesFileForSymbols($this->discoveredSymbols);
614
615        $vendorComposerAutoload = new VendorComposerAutoload(
616            $this->config,
617            $this->filesystem,
618            $this->logger
619        );
620        $vendorComposerAutoload->addAliasesFileToComposer();
621        $vendorComposerAutoload->addVendorPrefixedAutoloadToVendorAutoload();
622    }
623
624    protected function prefixComposerAutoloadFiles() : void
625    {
626
627        $this->replacer->prefixComposerAutoloadFiles($this->config->getAbsoluteTargetDirectory());
628    }
629
630    /**
631     *
632     * Delete source files if desired.
633     * Delete empty directories in destination.
634     */
635    protected function cleanUp(): void
636    {
637
638        $this->logger->notice('Cleaning up...');
639
640        $cleanup = new Cleanup(
641            $this->config,
642            $this->filesystem,
643            $this->logger
644        );
645
646        // This will check the config to check should it delete or not.
647        $cleanup->deleteFiles($this->flatDependencyTree, $this->discoveredFiles);
648
649        $cleanup->cleanupVendorInstalledJson($this->flatDependencyTree, $this->discoveredSymbols);
650        if ($this->config->isDeleteVendorFiles() || $this->config->isDeleteVendorPackages()) {
651            // Rebuild the autoloader after cleanup.
652            // This is needed because cleanup may have deleted files that were in the autoloader.
653            $cleanup->rebuildVendorAutoloader();
654        }
655    }
656}