vaimo/composer-patches

View on GitHub
src/Composer/Commands/ValidateCommand.php

Summary

Maintainability
B
5 hrs
Test Coverage
<?php
/**
 * Copyright © Vaimo Group. All rights reserved.
 * See LICENSE_VAIMO.txt for license details.
 */
namespace Vaimo\ComposerPatches\Composer\Commands;

use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputOption;

use Composer\Repository\WritableRepositoryInterface as PackageRepository;

use Vaimo\ComposerPatches\Composer\ConfigKeys;
use Vaimo\ComposerPatches\Config;
use Vaimo\ComposerPatches\Patch\Definition as Patch;
use Vaimo\ComposerPatches\Patch\DefinitionList\Loader\ComponentPool;
use Vaimo\ComposerPatches\Composer\Context as ComposerContext;
use Vaimo\ComposerPatches\Utils\PathUtils;

/**
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 */
class ValidateCommand extends \Composer\Command\BaseCommand
{
    protected function configure()
    {
        parent::configure();

        $this->setName('patch:validate');

        $this->setDescription('Validate that all patches have proper target configuration');

        $this->addOption(
            '--from-source',
            null,
            InputOption::VALUE_NONE,
            'Use latest information from package configurations in vendor folder'
        );

        $this->addOption(
            '--local',
            null,
            InputOption::VALUE_NONE,
            'Only validate patches that are owned by the ROOT package'
        );
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $output->writeln('<info>Scanning packages for orphan patches</info>');

        $composer = $this->getComposer();

        $localOnly = $input->getOption('local');

        $patchListAnalyser = new \Vaimo\ComposerPatches\Patch\DefinitionList\Analyser();

        $pluginConfig = array(
            Config::PATCHER_SOURCES => $this->createSourcesEnablerConfig($localOnly)
        );

        $contextFactory = new \Vaimo\ComposerPatches\Factories\ComposerContextFactory($composer);
        $composerContext = $contextFactory->create();

        $configFactory = new \Vaimo\ComposerPatches\Factories\ConfigFactory($composerContext, array(
            Config::PATCHER_FROM_SOURCE => (bool)$input->getOption('from-source')
        ));

        $repository = $composer->getRepositoryManager()->getLocalRepository();

        $pluginConfig = $configFactory->create(array($pluginConfig));

        $patchesLoader = $this->createPatchesLoader($composerContext, $pluginConfig);

        $patches = $patchesLoader->loadFromPackagesRepository($repository);

        $patchPaths = $patchListAnalyser->extractValue($patches, array(Patch::PATH, Patch::SOURCE));

        $patchDefines = array_combine(
            $patchPaths,
            $patchListAnalyser->extractDictionary($patches, array(Patch::OWNER, Patch::URL))
        );

        $patchStatuses = array_filter(
            array_combine(
                $patchPaths,
                $patchListAnalyser->extractValue($patches, array(Patch::STATUS_LABEL))
            ) ?: array()
        );

        $matches = $this->resolveValidationTargets($repository, $pluginConfig);
        $installPaths = $this->collectInstallPaths($matches);
        $fileMatches = $this->collectPatchFilesFromPackages($matches, $pluginConfig);
        $groups = $this->collectOrphans($fileMatches, $patchDefines, $installPaths, $patchStatuses);
        $this->outputOrphans($output, $groups);

        $output->writeln(
            $groups ? '<error>Orphans found!</error>' : '<info>Validation completed successfully</info>'
        );

        return (int)(bool)$groups;
    }

    private function createSourcesEnablerConfig($localOnly)
    {
        $configDefaults = new Config\Defaults();

        $defaultValues = $configDefaults->getPatcherConfig();

        if (isset($defaultValues[Config::PATCHER_SOURCES]) && is_array($defaultValues[Config::PATCHER_SOURCES])) {
            $sourceKeys = array_keys((array)$defaultValues[Config::PATCHER_SOURCES]);

            return $localOnly
                ? array_replace(array_fill_keys($sourceKeys, false), array('project' => true))
                : array_fill_keys($sourceKeys, true);
        }

        return array();
    }

    private function collectInstallPaths(array $matches)
    {
        $composer = $this->getComposer();
        $projectRoot = getcwd();

        $installationManager = $composer->getInstallationManager();

        $installPaths = array();
        foreach ($matches as $packageName => $package) {
            $installPaths[$packageName] = $package instanceof \Composer\Package\RootPackageInterface
                ? $projectRoot
                : $installationManager->getInstallPath($package);
        }

        return $installPaths;
    }

    private function collectPatchFilesFromPackages(array $matches, Config $pluginConfig)
    {
        $composer = $this->getComposer();
        $composerConfig = $composer->getConfig();

        $configReaderFactory = new \Vaimo\ComposerPatches\Factories\PatcherConfigReaderFactory($composer);
        $dataUtils = new \Vaimo\ComposerPatches\Utils\DataUtils();
        $filterUtils = new \Vaimo\ComposerPatches\Utils\FilterUtils();
        $fileSystemUtils = new \Vaimo\ComposerPatches\Utils\FileSystemUtils();

        $projectRoot = getcwd();

        $vendorRoot = $composerConfig->get(ConfigKeys::VENDOR_DIR);
        $vendorPath = ltrim(
            substr($vendorRoot, strlen($projectRoot)),
            DIRECTORY_SEPARATOR
        );

        $defaultIgnores = array($vendorPath, '.hg', '.git', '.idea');

        $patcherConfigReader = $configReaderFactory->create($pluginConfig);

        $installPaths = $this->collectInstallPaths($matches);

        $fileMatchGroups = array();

        foreach ($matches as $packageName => $package) {
            $patcherConfig = $patcherConfigReader->readFromPackage($package);

            $ignores = $dataUtils->getValueByPath(
                $patcherConfig,
                array(Config::PATCHER_CONFIG_ROOT, Config::PATCHES_IGNORE),
                array()
            );

            $installPath = $installPaths[$packageName];

            $skippedPaths = $dataUtils->prefixArrayValues(
                array_merge($defaultIgnores, $ignores),
                $installPath . DIRECTORY_SEPARATOR
            );

            $filter = $filterUtils->composeRegex(
                $filterUtils->invertRules($skippedPaths),
                '/'
            );

            $filter = sprintf('%s/i', rtrim($filter, '/') . Config::PATCH_FILE_REGEX_MATCHER);
            $searchResult = $fileSystemUtils->collectFilePathsRecursively($installPath, $filter);

            $fileMatchGroups[] = array_fill_keys($searchResult, array(
                Patch::OWNER => $packageName,
                Patch::URL => ''
            ));
        }

        return array_reduce($fileMatchGroups, 'array_replace', array());
    }

    private function resolveValidationTargets(PackageRepository $repository, Config $pluginConfig)
    {
        $composer = $this->getComposer();

        $packageResolver = new \Vaimo\ComposerPatches\Composer\Plugin\PackageResolver(
            array($composer->getPackage())
        );

        $srcResolverFactory = new \Vaimo\ComposerPatches\Factories\SourcesResolverFactory($composer);
        $packageListUtils = new \Vaimo\ComposerPatches\Utils\PackageListUtils();

        $srcResolver = $srcResolverFactory->create($pluginConfig);

        $sources = $srcResolver->resolvePackages($repository);

        $repositoryUtils = new \Vaimo\ComposerPatches\Utils\RepositoryUtils();

        $pluginPackage = $packageResolver->resolveForNamespace(
            $repository->getCanonicalPackages(),
            __NAMESPACE__
        );

        $pluginName = $pluginPackage->getName();

        $pluginUsers = array_merge(
            $repositoryUtils->filterByDependency($repository, $pluginName),
            array($composer->getPackage())
        );

        return array_intersect_key(
            $packageListUtils->listToNameDictionary($sources),
            $packageListUtils->listToNameDictionary($pluginUsers)
        );
    }

    private function createPatchesLoader(ComposerContext $composerContext, Config $pluginConfig)
    {
        $composer = $this->getComposer();

        $loaderFactory = new \Vaimo\ComposerPatches\Factories\PatchesLoaderFactory($composer);

        $componentOverrides = array(
            'constraints' => false,
            'platform' => false,
            'targets-resolver' => false,
            'local-exclude' => false,
            'root-patch' => false,
            'global-exclude' => false
        );

        $loaderComponentsPool = $this->createLoaderPool($composerContext, $componentOverrides);

        return $loaderFactory->create($loaderComponentsPool, $pluginConfig, true);
    }

    private function createLoaderPool(ComposerContext $composerContext, array $componentUpdates = array())
    {
        $appIO = $this->getIO();

        $componentPool = new ComponentPool($composerContext, $appIO, true);

        foreach ($componentUpdates as $componentName => $replacement) {
            $componentPool->registerComponent($componentName, $replacement);
        }

        return $componentPool;
    }

    private function collectOrphans(array $files, array $patches, array $paths, array $statuses)
    {
        $orphanFiles = array_diff_key($files, $patches);
        $orphanConfig = array_diff_key($patches, $files);

        /**
         * Make sure that downloaded patches are not perceived as missing files
         */
        $orphanConfig = array_diff_key(
            $orphanConfig,
            array_flip(
                array_filter(array_keys($orphanConfig), 'file_exists')
            )
        );

        $groups = array_fill_keys(array_keys($paths), array());

        $results = array(
            $this->findWithMissingConfig($orphanFiles, $paths),
            $this->findWithMissingFile($orphanConfig, $paths, $statuses)
        );

        foreach ($results as $result) {
            foreach ($result as $ownerName => $items) {
                $groups[$ownerName] = array_merge($groups[$ownerName], $items);
            }
        }

        return array_filter($groups);
    }

    private function findWithMissingConfig($orphanFiles, $paths)
    {
        $pathFlags = array_fill_keys(array_keys($paths), true);
        $groups = array();

        foreach ($orphanFiles as $path => $config) {
            $ownerName = $config[Patch::OWNER];
            $installPath = $paths[$ownerName];

            if (!isset($pathFlags[$ownerName])) {
                continue;
            }

            $groups[$ownerName][] = array(
                'issue' => 'NO CONFIG',
                'path' => $config[Patch::URL] ?: PathUtils::reducePathLeft($path, $installPath)
            );
        }

        return $groups;
    }

    private function findWithMissingFile($orphanConfig, $paths, $statuses)
    {
        $pathFlags = array_fill_keys(array_keys($paths), true);
        $groups = array();

        foreach ($orphanConfig as $path => $config) {
            $ownerName = $config[Patch::OWNER];
            $installPath = $paths[$ownerName];

            if (!isset($pathFlags[$ownerName])) {
                continue;
            }

            $groups[$ownerName][] = array(
                'issue' => isset($statuses[$path]) && $statuses[$path]
                    ? $statuses[$path]
                    : 'NO FILE',
                'path' => $config[Patch::URL] ?: PathUtils::reducePathLeft($path, $installPath)
            );
        }

        return $groups;
    }

    private function outputOrphans(OutputInterface $output, array $groups)
    {
        $lines = array();

        foreach ($groups as $packageName => $items) {
            $lines[] = sprintf('  - <info>%s</info>', $packageName);

            foreach ($items as $item) {
                $lines[] = sprintf(
                    '    ~ %s [<fg=red>%s</>]',
                    $item['path'],
                    $item['issue']
                );
            }
        }

        foreach ($lines as $line) {
            $output->writeln($line);
        }
    }
}