Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
29.39% covered (danger)
29.39%
87 / 296
5.88% covered (danger)
5.88%
1 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
DependenciesCommand
29.39% covered (danger)
29.39%
87 / 296
5.88% covered (danger)
5.88%
1 / 17
757.83
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
18.18% covered (danger)
18.18%
8 / 44
0.00% covered (danger)
0.00%
0 / 1
12.76
 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 / 19
0.00% covered (danger)
0.00%
0 / 1
2
 enumeratePsr4Namespaces
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 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 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 determineChanges
0.00% covered (danger)
0.00%
0 / 13
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 / 28
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 / 15
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            $this->determineChanges();
254
255            $this->enumeratePsr4Namespaces();
256
257            $this->performReplacements();
258
259            $this->performReplacementsInProjectFiles();
260
261            $this->addLicenses();
262
263            $this->cleanUp();
264
265            $this->generateAutoloader();
266
267            // After files have been deleted, we may need aliases.
268            $this->generateAliasesFile();
269
270            $this->logger->notice('Done');
271        } catch (Exception $e) {
272            $this->logger->error($e->getMessage());
273
274            return Command::FAILURE;
275        }
276
277        return Command::SUCCESS;
278    }
279
280    /**
281     * 1. Load the composer.json.
282     *
283     * @throws Exception
284     */
285    protected function loadProjectComposerPackage(): void
286    {
287        $this->logger->notice('Loading package...');
288
289
290        $composerFilePath = $this->filesystem->normalize($this->workingDir . Factory::getComposerFile());
291        $defaultComposerFilePath = $this->filesystem->normalize($this->workingDir . 'composer.json');
292        if ($composerFilePath !== $defaultComposerFilePath) {
293            $this->logger->info('Using: ' . $composerFilePath);
294        }
295
296        $this->projectComposerPackage = new ProjectComposerPackage('/'.$composerFilePath);
297
298        // TODO: Print the config that Strauss is using.
299        // Maybe even highlight what is default config and what is custom config.
300    }
301
302    protected function loadConfigFromComposerJson(): void
303    {
304        $this->logger->notice('Loading composer.json config...');
305
306        $this->config = $this->projectComposerPackage->getStraussConfig();
307    }
308
309    protected function updateConfigFromCli(InputInterface $input): void
310    {
311        $this->logger->notice('Loading cli config...');
312
313        $this->config->updateFromCli($input);
314    }
315
316    /**
317     * 2. Built flat list of packages and dependencies.
318     *
319     * 2.1 Initiate getting dependencies for the project composer.json.
320     *
321     * @see DependenciesCommand::flatDependencyTree
322     */
323    protected function buildDependencyList(): void
324    {
325        $this->logger->notice('Building dependency list...');
326
327        $this->dependenciesEnumerator = new DependenciesEnumerator(
328            $this->config,
329            $this->filesystem,
330            $this->logger
331        );
332        $this->flatDependencyTree = $this->dependenciesEnumerator->getAllDependencies();
333
334        $this->config->setPackagesToCopy(
335            array_filter($this->flatDependencyTree, function ($dependency) {
336                return !in_array($dependency, $this->config->getExcludePackagesFromCopy());
337            },
338            ARRAY_FILTER_USE_KEY)
339        );
340
341        $this->config->setPackagesToPrefix(
342            array_filter($this->flatDependencyTree, function ($dependency) {
343                return !in_array($dependency, $this->config->getExcludePackagesFromPrefixing());
344            },
345            ARRAY_FILTER_USE_KEY)
346        );
347
348        // TODO: Print the dependency tree that Strauss has determined.
349    }
350
351    /**
352     * TODO: currently this must run after ::determineChanges() so the discoveredSymbols object exists,
353     * but logically it should run first.
354     */
355    protected function enumeratePsr4Namespaces(): void
356    {
357        foreach ($this->config->getPackagesToPrefix() as $package) {
358            $autoloadKey = $package->getAutoload();
359            if (! isset($autoloadKey['psr-4'])) {
360                continue;
361            }
362
363            $psr4autoloadKey = $autoloadKey['psr-4'];
364            $namespaces = array_keys($psr4autoloadKey);
365
366            $file = new File($package->getPackageAbsolutePath() . 'composer.json');
367
368            foreach ($namespaces as $namespace) {
369                // TODO: log.
370                $symbol = new NamespaceSymbol(trim($namespace, '\\'), $file);
371                // TODO: respect all config options.
372                $symbol->setReplacement($this->config->getNamespacePrefix() . '\\' . trim($namespace, '\\'));
373                $this->discoveredSymbols->add($symbol);
374            }
375        }
376    }
377
378    protected function enumerateFiles(): void
379    {
380        $this->logger->notice('Enumerating files...');
381
382        $fileEnumerator = new FileEnumerator(
383            $this->config,
384            $this->filesystem,
385            $this->logger
386        );
387
388        $this->discoveredFiles = $fileEnumerator->compileFileListForDependencies($this->flatDependencyTree);
389    }
390
391    // 3. Copy autoloaded files for each
392    protected function copyFiles(): void
393    {
394        (new FileCopyScanner($this->config, $this->filesystem, $this->logger))->scanFiles($this->discoveredFiles);
395
396        if ($this->config->getTargetDirectory() === $this->config->getVendorDirectory()) {
397            // Nothing to do.
398            return;
399        }
400
401        $this->logger->notice('Copying files...');
402
403        $copier = new Copier(
404            $this->discoveredFiles,
405            $this->config,
406            $this->filesystem,
407            $this->logger
408        );
409
410
411        $copier->prepareTarget();
412        $copier->copy();
413
414        $installedJson = new InstalledJson(
415            $this->config,
416            $this->filesystem,
417            $this->logger
418        );
419        $installedJson->copyInstalledJson();
420    }
421
422    // 4. Determine namespace and classname changes
423    protected function determineChanges(): void
424    {
425        $this->logger->notice('Determining changes...');
426
427        $fileScanner = new FileSymbolScanner(
428            $this->config,
429            $this->filesystem,
430            $this->logger
431        );
432
433        $this->discoveredSymbols = $fileScanner->findInFiles($this->discoveredFiles);
434
435        $changeEnumerator = new ChangeEnumerator(
436            $this->config,
437            $this->filesystem,
438            $this->logger
439        );
440        $changeEnumerator->determineReplacements($this->discoveredSymbols);
441    }
442
443    // 5. Update namespaces and class names.
444    // Replace references to updated namespaces and classnames throughout the dependencies.
445    protected function performReplacements(): void
446    {
447        $this->logger->notice('Performing replacements...');
448
449        $this->replacer = new Prefixer(
450            $this->config,
451            $this->filesystem,
452            $this->logger
453        );
454
455        $this->replacer->replaceInFiles($this->discoveredSymbols, $this->discoveredFiles->getFiles());
456    }
457
458    protected function performReplacementsInProjectFiles(): void
459    {
460
461        $relativeCallSitePaths =
462            $this->config->getUpdateCallSites()
463            ?? $this->projectComposerPackage->getFlatAutoloadKey();
464
465        if (empty($relativeCallSitePaths)) {
466            return;
467        }
468
469        $callSitePaths = array_map(
470            fn($path) => $this->workingDir . $path,
471            $relativeCallSitePaths
472        );
473
474        $projectReplace = new Prefixer(
475            $this->config,
476            $this->filesystem,
477            $this->logger
478        );
479
480        $fileEnumerator = new FileEnumerator(
481            $this->config,
482            $this->filesystem
483        );
484
485        $projectFiles = $fileEnumerator->compileFileListForPaths($callSitePaths);
486
487        $phpFiles = array_filter(
488            $projectFiles->getFiles(),
489            fn($file) => $file->isPhpFile()
490        );
491
492        $phpFilesAbsolutePaths = array_map(
493            fn($file) => $file->getSourcePath(),
494            $phpFiles
495        );
496
497        // TODO: Warn when a file that was specified is not found
498        // $this->logger->warning('Expected file not found from project autoload: ' . $absolutePath);
499
500        $projectReplace->replaceInProjectFiles($this->discoveredSymbols, $phpFilesAbsolutePaths);
501    }
502
503    protected function addLicenses(): void
504    {
505        $this->logger->notice('Adding licenses...');
506
507        $author = $this->projectComposerPackage->getAuthor();
508
509        $dependencies = $this->flatDependencyTree;
510
511        $licenser = new Licenser(
512            $this->config,
513            $dependencies,
514            $author,
515            $this->filesystem,
516            $this->logger
517        );
518
519        $licenser->copyLicenses();
520
521        $modifiedFiles = $this->replacer->getModifiedFiles();
522        $licenser->addInformationToUpdatedFiles($modifiedFiles);
523    }
524
525    /**
526     * 6. Generate autoloader.
527     */
528    protected function generateAutoloader(): void
529    {
530        if (isset($this->projectComposerPackage->getAutoload()['classmap'])
531            && in_array(
532                $this->config->getTargetDirectory(),
533                $this->projectComposerPackage->getAutoload()['classmap'],
534                true
535            )
536        ) {
537            $this->logger->notice('Skipping autoloader generation as target directory is in Composer classmap. Run `composer dump-autoload`.');
538            return;
539        }
540
541        $this->logger->notice('Generating autoloader...');
542
543        $allFilesAutoloaders = $this->dependenciesEnumerator->getAllFilesAutoloaders();
544        $filesAutoloaders = array();
545        foreach ($allFilesAutoloaders as $packageName => $packageFilesAutoloader) {
546            if (in_array($packageName, $this->config->getExcludePackagesFromCopy())) {
547                continue;
548            }
549            $filesAutoloaders[$packageName] = $packageFilesAutoloader;
550        }
551
552        $classmap = new Autoload(
553            $this->config,
554            $filesAutoloaders,
555            $this->filesystem,
556            $this->logger
557        );
558
559        $classmap->generate($this->flatDependencyTree, $this->discoveredSymbols);
560    }
561
562    /**
563     * When namespaces are prefixed which are used by both require and require-dev dependencies,
564     * the require-dev dependencies need class aliases specified to point to the new class names/namespaces.
565     */
566    protected function generateAliasesFile(): void
567    {
568        if (!$this->config->isCreateAliases()) {
569            return;
570        }
571
572        $this->logger->notice('Generating aliases file...');
573
574        $aliases = new Aliases(
575            $this->config,
576            $this->filesystem
577        );
578        $aliases->writeAliasesFileForSymbols($this->discoveredSymbols);
579
580        $vendorComposerAutoload = new VendorComposerAutoload(
581            $this->config,
582            $this->filesystem,
583            $this->logger
584        );
585        $vendorComposerAutoload->addAliasesFileToComposer();
586        $vendorComposerAutoload->addVendorPrefixedAutoloadToVendorAutoload();
587    }
588
589    /**
590     * 7.
591     * Delete source files if desired.
592     * Delete empty directories in destination.
593     */
594    protected function cleanUp(): void
595    {
596
597        $this->logger->notice('Cleaning up...');
598
599        $cleanup = new Cleanup(
600            $this->config,
601            $this->filesystem,
602            $this->logger
603        );
604
605        // This will check the config to check should it delete or not.
606        $cleanup->deleteFiles($this->discoveredFiles->getFiles());
607
608        $cleanup->cleanupVendorInstalledJson($this->flatDependencyTree, $this->discoveredSymbols);
609        if ($this->config->isDeleteVendorFiles() || $this->config->isDeleteVendorPackages()) {
610            // Rebuild the autoloader after cleanup.
611            // This is needed because cleanup may have deleted files that were in the autoloader.
612            $cleanup->rebuildVendorAutoloader();
613        }
614    }
615}