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