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