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