diff --git a/.github/workflows/code_analysis.yaml b/.github/workflows/code_analysis.yaml index ba6ccf2..8c428d2 100644 --- a/.github/workflows/code_analysis.yaml +++ b/.github/workflows/code_analysis.yaml @@ -34,19 +34,19 @@ jobs: - name: 'Check Active Classes' - run: vendor/bin/class-leak check bin src tests --ansi --skip-type="Rector\Monitor\Compare\Contract\ComparatorInterface" + run: vendor/bin/class-leak check bin src tests --ansi --skip-type="Rector\Monitor\Compare\Contract\ComparatorInterface" --skip-type="Entropy\Console\Contract\CommandInterface" --skip-type="\Rector\Monitor\Analyze\Contract\RuleProcessorInterface" - - name: 'Run "Compare Projects" command' - run: php bin/monitor compare-projects tests/project-fixture/first-project --merge-project tests/project-fixture/second-project --ansi + name: 'Run "Analyze"' + run: php bin/monitor analyze - - name: 'Run "Matrix"' - run: php bin/monitor matrix --ansi + name: 'Run "Compare"' + run: php bin/monitor compare-projects tests/project-fixture/first-project --merge-project tests/project-fixture/second-project - - name: 'Run "Analyze"' - run: php bin/monitor analyze --ansi + name: 'Run "Matrix"' + run: php bin/monitor matrix - name: 'Composer dependency Analyser' diff --git a/README.md b/README.md index d32d9f2..965c25a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # [PRIVATE BETA] Monitor quality of many Packages and Projects in one place -[![License](https://img.shields.io/packagist/l/rector/monitor.svg?style=flat-square)](https://packagist.org/packages/rector/monitor) [![Downloads total](https://img.shields.io/packagist/dt/rector/monitor.svg?style=flat-square)](https://packagist.org/packages/rector/monitor/stats) Monitor code quality for all your projects and packages in one place, ensuring consistency and eliminating conflicts across PHP versions. @@ -42,7 +41,7 @@ return MonitorConfig::configure() // ->addRepositoryBranch('...', 'stage') // composer rules - // ->disallowPackages(['symfony/phpunit-bridge']) + ->disallowPackages(['symfony/phpunit-bridge']) ->requirePackages([ 'rector/rector', 'phpecs/phpecs', diff --git a/bin/monitor.php b/bin/monitor.php index f44a2a6..f773468 100755 --- a/bin/monitor.php +++ b/bin/monitor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Rector\Monitor\Console\MonitorConsoleApplication; +use Entropy\Console\ConsoleApplication; use Rector\Monitor\DependencyInjection\ContainerFactory; $scoperAutoloadFilepath = __DIR__ . '/../vendor/scoper-autoload.php'; @@ -30,5 +30,7 @@ $containerFactory = new ContainerFactory(); $container = $containerFactory->create(); -$application = $container->make(MonitorConsoleApplication::class); -exit($application->run()); +$application = $container->make(ConsoleApplication::class); +$exitCode = $application->run($argv); + +exit($exitCode); diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index d7fd23e..f8739d6 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -4,4 +4,6 @@ use ShipMonk\ComposerDependencyAnalyser\Config\Configuration; -return new Configuration(); +return (new Configuration()) + // conditional use + ->ignoreErrorsOnExtension('ext-mbstring', [\ShipMonk\ComposerDependencyAnalyser\Config\ErrorType::SHADOW_DEPENDENCY]); diff --git a/composer.json b/composer.json index 54c74a8..563a2ca 100644 --- a/composer.json +++ b/composer.json @@ -10,21 +10,19 @@ "composer/semver": "^3.4", "entropy/entropy": "dev-main", "nette/neon": "^3.4", - "nette/utils": "^4.1", - "symfony/filesystem": "^7.4", - "symfony/console": "^6.4", - "symfony/finder": "^7.4", - "symfony/process": "^7.4", - "webmozart/assert": "^1.12" + "symfony/filesystem": "^7.4|8.0.*", + "symfony/finder": "^7.4|8.0.*", + "symfony/process": "^7.4|8.0.*", + "webmozart/assert": "^1.12|^2.0" }, "require-dev": { - "phpecs/phpecs": "^2.2", + "phpecs/phpecs": "^2.3", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.1", "phpstan/phpstan-deprecation-rules": "^2.0", - "phpunit/phpunit": "^11.5", - "rector/jack": "^0.4.0", - "rector/rector": "^2.2", + "phpunit/phpunit": "^12.5", + "rector/jack": "^0.5", + "rector/rector": "^2.3", "shipmonk/composer-dependency-analyser": "^1.8", "symplify/phpstan-extensions": "^12.0", "symplify/phpstan-rules": "^14.9", diff --git a/phpstan.neon b/phpstan.neon index de7d534..3104771 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -16,3 +16,11 @@ parameters: treatPhpDocTypesAsCertain: false errorFormat: symplify + + ignoreErrors: + # part of entropy magic contract + - '#Public method "Rector\\(.*?)Command\:\:run\(\)" is never used#' + + # too detailed + - '#Parameter (.*?) expects list, (.*?), int<0, max>> given#' + diff --git a/src/Analyze/Command/AnalyzeCommand.php b/src/Analyze/Command/AnalyzeCommand.php index edc119a..f8e6a86 100644 --- a/src/Analyze/Command/AnalyzeCommand.php +++ b/src/Analyze/Command/AnalyzeCommand.php @@ -4,79 +4,60 @@ namespace Rector\Monitor\Analyze\Command; +use Entropy\Console\Contract\CommandInterface; +use Entropy\Console\Enum\ExitCode; +use Entropy\Console\Output\OutputPrinter; use Rector\Monitor\Analyze\Contract\RuleProcessorInterface; use Rector\Monitor\Analyze\Reporting\ErrorCollector; -use Rector\Monitor\Analyze\RuleProcessor\DisallowedPackagesRuleProcessor; -use Rector\Monitor\Analyze\RuleProcessor\MetafileProcessor\NoPHPStanBaselineMetafileProcessor; -use Rector\Monitor\Analyze\RuleProcessor\MissingPackagesRuleProcessor; -use Rector\Monitor\Analyze\RuleProcessor\TooLowPackagesRulesProcessor; use Rector\Monitor\Config\ConfigInitializer; use Rector\Monitor\Config\MonitorConfigProvider; use Rector\Monitor\Git\RepositoryMetafilesResolver; -use Rector\Monitor\ValueObject\RepositoryCollection; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; - -final class AnalyzeCommand extends Command +use Webmozart\Assert\Assert; + +final readonly class AnalyzeCommand implements CommandInterface { /** - * @var RuleProcessorInterface[] + * @param RuleProcessorInterface[] $ruleProcessors */ - private array $ruleProcessors; - public function __construct( - private readonly SymfonyStyle $symfonyStyle, - private readonly MonitorConfigProvider $monitorConfigProvider, - private readonly RepositoryMetafilesResolver $repositoryMetafilesResolver, - private readonly ConfigInitializer $configInitializer, - DisallowedPackagesRuleProcessor $disallowedPackagesRuleProcessor, - MissingPackagesRuleProcessor $missingPackagesRuleProcessor, - TooLowPackagesRulesProcessor $tooLowPackagesRulesProcessor, - NoPHPStanBaselineMetafileProcessor $noPHPStanBaselineMetafileProcessor + private OutputPrinter $outputPrinter, + private MonitorConfigProvider $monitorConfigProvider, + private RepositoryMetafilesResolver $repositoryMetafilesResolver, + private ConfigInitializer $configInitializer, + private array $ruleProcessors, ) { - parent::__construct(); - - $this->ruleProcessors[] = $disallowedPackagesRuleProcessor; - $this->ruleProcessors[] = $missingPackagesRuleProcessor; - $this->ruleProcessors[] = $tooLowPackagesRulesProcessor; - $this->ruleProcessors[] = $noPHPStanBaselineMetafileProcessor; + Assert::notEmpty($ruleProcessors); } - protected function configure(): void + public function getName(): string { - $this->setName('analyze'); - $this->setAliases(['analyse']); - - $this->setDescription( - 'Check repositories composer.json files, assess "require" section, root files, tooling setup and min PHP version with defined quality control rules' - ); + return 'analyze'; + } - $this->addOption( - 'clear-cache', - null, - InputOption::VALUE_NONE, - 'Remove cached repositoryCollection composer.json files' - ); + public function getDescription(): string + { + return 'Analyze multiple repositories for required packages, root files, min PHP version etc. against defined standard'; } - protected function execute(InputInterface $input, OutputInterface $output): int + /** + * @param bool $clearCache Remove cached repositoryCollection composer.json files + * @return ExitCode::* + */ + public function run(bool $clearCache = false, bool $debug = false): int { if ($this->configInitializer->createConfigIfMissing(getcwd())) { - return self::SUCCESS; + return ExitCode::SUCCESS; } $monitorConfig = $this->monitorConfigProvider->provide(); - $shouldClearCache = (bool) $input->getOption('clear-cache'); $this->repositoryMetafilesResolver->decorateRepositories( $monitorConfig->getRepositoryCollection(), - $shouldClearCache + $clearCache, + $debug ); - $this->symfonyStyle->title('Repository analysis report'); + $this->outputPrinter->title('Repository analysis report'); $erroredRepositoryCount = 0; @@ -85,42 +66,45 @@ protected function execute(InputInterface $input, OutputInterface $output): int $composerJson = $repository->getComposerJson(); - $this->symfonyStyle->writeln( - sprintf('%d) %s', $key + 1, $composerJson->getRepositoryName()) + $this->outputPrinter->writeln( + sprintf('%d) %s', $key + 1, $composerJson->getRepositoryName()) ); foreach ($this->ruleProcessors as $ruleProcessor) { $ruleProcessor->process($monitorConfig, $composerJson, $errorCollector); } foreach ($errorCollector->getErrorMessages() as $errorMessage) { - $this->symfonyStyle->writeln($errorMessage); + $this->outputPrinter->writeln($errorMessage); } if ($errorCollector->hasErrors() === false) { - $this->symfonyStyle->writeln(' * Package perfect ✔'); + $this->outputPrinter->greenBackground('Package perfect ✔'); } else { ++$erroredRepositoryCount; } - $this->symfonyStyle->newLine(); + $this->outputPrinter->newLine(); } - $this->printAnalysisSummary($monitorConfig->getRepositoryCollection(), $erroredRepositoryCount); + $this->printAnalysisSummary($erroredRepositoryCount); - return self::SUCCESS; + return ExitCode::SUCCESS; } - private function printAnalysisSummary(RepositoryCollection $repositoryCollection, int $erroredRepositoryCount): void + private function printAnalysisSummary(int $erroredRepositoryCount): void { - $this->symfonyStyle->newLine(); - - $this->symfonyStyle->writeln(sprintf( - ' Summary: %d %s analyzed, %d with issues', - $repositoryCollection->count(), - $repositoryCollection->count() === 1 ? 'repository' : 'repositories', - $erroredRepositoryCount - )); + $this->outputPrinter->newLine(); + + if ($erroredRepositoryCount > 0) { + $this->outputPrinter->redBackground(sprintf( + 'Found %d %s', + $erroredRepositoryCount, + $erroredRepositoryCount === 1 ? 'issue' : 'issues' + )); + } else { + $this->outputPrinter->greenBackground('All repositories are perfect ✔'); + } - $this->symfonyStyle->newLine(); + $this->outputPrinter->newLine(); } } diff --git a/src/Compare/Command/CompareProjectsCommand.php b/src/Compare/Command/CompareProjectsCommand.php index 11f1918..70064b1 100644 --- a/src/Compare/Command/CompareProjectsCommand.php +++ b/src/Compare/Command/CompareProjectsCommand.php @@ -4,58 +4,54 @@ namespace Rector\Monitor\Compare\Command; +use Entropy\Console\Contract\CommandInterface; +use Entropy\Console\Output\OutputPrinter; +use Rector\Console\ExitCode; use Rector\Monitor\Compare\Contract\ComparatorInterface; use Rector\Monitor\Compare\ValueObject\ProjectMetadata; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; use Webmozart\Assert\Assert; -final class CompareProjectsCommand extends Command +final readonly class CompareProjectsCommand implements CommandInterface { /** * @param ComparatorInterface[] $comparators */ public function __construct( - private readonly SymfonyStyle $symfonyStyle, - private readonly array $comparators + private OutputPrinter $outputPrinter, + private array $comparators ) { - parent::__construct(); - // make sure not empty to verify DI works Assert::notEmpty($comparators); Assert::allIsInstanceOf($comparators, ComparatorInterface::class); } - protected function configure(): void + public function getName(): string { - $this->setName('compare-projects'); - - $this->setDescription( - 'Compare two projects and show the differences. First is the macro, monorepo project we want to merge into. Other is the project to merge merge and dismantle later.' - ); + return 'compare-projects'; + } - $this->addArgument('monorepo-directory', InputArgument::REQUIRED, 'Path to the monorepo directory'); - $this->addOption('merge-project', null, InputOption::VALUE_REQUIRED); + public function getDescription(): string + { + return 'Compare 2 projects and show their differences - configs, required packages, PHPStan extensions, levels etc.'; } - protected function execute(InputInterface $input, OutputInterface $output): int + /** + * @param string $monorepoDirectory Path to the monorepo directory + * @param string $mergeProject Path to the project to merge directory + * + * @return ExitCode::* + */ + public function run(string $monorepoDirectory, string $mergeProject): int { - $monorepoDirectory = $input->getArgument('monorepo-directory'); $monorepoProjectMetadata = new ProjectMetadata($monorepoDirectory); + $mergeProjectMetadata = new ProjectMetadata($mergeProject); - $mergeDirectory = $input->getOption('merge-project'); - $mergeProjectMetadata = new ProjectMetadata($mergeDirectory); - - $this->symfonyStyle->writeln('Both directories found. Comparing 2 projects'); + $this->outputPrinter->green('Both directories found. Comparing 2 projects'); foreach ($this->comparators as $comparator) { $comparator->compare($monorepoProjectMetadata, $mergeProjectMetadata); } - return self::SUCCESS; + return \Entropy\Console\Enum\ExitCode::SUCCESS; } } diff --git a/src/Compare/Comparator/ComposerAutoloadComparator.php b/src/Compare/Comparator/ComposerAutoloadComparator.php index d002cd5..bcfad7b 100644 --- a/src/Compare/Comparator/ComposerAutoloadComparator.php +++ b/src/Compare/Comparator/ComposerAutoloadComparator.php @@ -4,11 +4,11 @@ namespace Rector\Monitor\Compare\Comparator; -use Nette\Utils\Json; +use Entropy\Console\Output\OutputPrinter; +use Entropy\Utils\Json; use Rector\Monitor\Compare\Contract\ComparatorInterface; use Rector\Monitor\Compare\Utils\ArrayUtils; use Rector\Monitor\Compare\ValueObject\ProjectMetadata; -use Symfony\Component\Console\Style\SymfonyStyle; final readonly class ComposerAutoloadComparator implements ComparatorInterface { @@ -18,7 +18,7 @@ private const array AUTOLOAD_KEYS = ['autoload', 'autoload-dev']; public function __construct( - private SymfonyStyle $symfonyStyle + private OutputPrinter $outputPrinter, ) { } @@ -26,7 +26,7 @@ public function compare( ProjectMetadata $monorepoProjectMetadata, ProjectMetadata $mergeProjectMetadata ): void { - $this->symfonyStyle->title('PSR-4 autoload differences'); + $this->outputPrinter->title('PSR-4 autoload differences'); $hasDifference = false; @@ -41,36 +41,37 @@ public function compare( if ($autoloadDiff !== []) { $hasDifference = true; - $this->symfonyStyle->writeln(sprintf( + $this->outputPrinter->writeln(sprintf( 'Monorepo project and project have different "%s":', $autoloadKey )); - $this->symfonyStyle->newLine(); - $this->symfonyStyle->writeln( + $this->outputPrinter->newLine(); + $this->outputPrinter->writeln( sprintf('Monorepo project ("%s")', $monorepoProjectMetadata->getName()) ); - $this->symfonyStyle->newLine(); - $this->symfonyStyle->writeln(Json::encode($monorepoComposerJson[$autoloadKey] ?? [], true)); + $this->outputPrinter->newLine(); - $this->symfonyStyle->newLine(); - $this->symfonyStyle->writeln(sprintf( + $this->outputPrinter->writeln(Json::encode($monorepoComposerJson[$autoloadKey] ?? [])); + + $this->outputPrinter->newLine(); + $this->outputPrinter->writeln(sprintf( 'Merge project ("%s")', $mergeProjectMetadata->getName() )); - $this->symfonyStyle->newLine(); - $this->symfonyStyle->writeln(Json::encode($mergeComposerJson[$autoloadKey] ?? [], true)); + $this->outputPrinter->newLine(); + $this->outputPrinter->writeln(Json::encode($mergeComposerJson[$autoloadKey] ?? [])); - $this->symfonyStyle->newLine(); + $this->outputPrinter->newLine(); } } if ($hasDifference === false) { - $this->symfonyStyle->success('Autoloads are identical, nothing spotted'); + $this->outputPrinter->greenBackground('Autoloads are identical, nothing spotted'); } - $this->symfonyStyle->newLine(); + $this->outputPrinter->newLine(); } } diff --git a/src/Compare/Comparator/MutuallyMissingPackagesComparator.php b/src/Compare/Comparator/MutuallyMissingPackagesComparator.php index f69e3e0..deddc6a 100644 --- a/src/Compare/Comparator/MutuallyMissingPackagesComparator.php +++ b/src/Compare/Comparator/MutuallyMissingPackagesComparator.php @@ -4,9 +4,9 @@ namespace Rector\Monitor\Compare\Comparator; +use Entropy\Console\Output\OutputPrinter; use Rector\Monitor\Compare\Contract\ComparatorInterface; use Rector\Monitor\Compare\ValueObject\ProjectMetadata; -use Symfony\Component\Console\Style\SymfonyStyle; final readonly class MutuallyMissingPackagesComparator implements ComparatorInterface { @@ -16,7 +16,7 @@ private const array REQUIRE_KEYS = ['require', 'require-dev']; public function __construct( - private SymfonyStyle $symfonyStyle + private OutputPrinter $outputPrinter ) { } @@ -24,7 +24,7 @@ public function compare( ProjectMetadata $monorepoProjectMetadata, ProjectMetadata $mergeProjectMetadata ): void { - $this->symfonyStyle->title('Composer dependencies'); + $this->outputPrinter->title('Composer dependencies'); $hasDifference = false; @@ -35,6 +35,7 @@ public function compare( $monorepoRequiredPackages = array_keys($monorepoComposerJson[$requireKey] ?? []); $mergeRequiredPackages = array_keys($mergeComposerJson[$requireKey] ?? []); + /** @var string[] $missingPackages */ $missingPackages = array_diff($mergeRequiredPackages, $monorepoRequiredPackages); sort($missingPackages); @@ -44,27 +45,30 @@ public function compare( $hasDifference = true; - $this->symfonyStyle->writeln(sprintf( + $this->outputPrinter->writeln(sprintf( 'Monorepo project missing couple dependencies in "%s":', $requireKey )); - $this->symfonyStyle->newLine(); - $this->symfonyStyle->listing($missingPackages); - $this->symfonyStyle->writeln('Add them via this command to the monorepo project:'); - $this->symfonyStyle->writeln(sprintf( + $this->outputPrinter->newLine(); + $this->outputPrinter->listing($missingPackages); + + $this->outputPrinter->green('Add them via this command to the monorepo project:'); + $this->outputPrinter->writeln(sprintf( 'composer require %s %s', implode(' ', $missingPackages), $requireKey === 'require-dev' ? '--dev' : '' )); - $this->symfonyStyle->newLine(); + $this->outputPrinter->newLine(); } if ($hasDifference === false) { - $this->symfonyStyle->success('Monorepo project has all required dependencies from the merge project'); + $this->outputPrinter->greenBackground( + 'Monorepo project has all required dependencies from the merge project' + ); } - $this->symfonyStyle->newLine(); + $this->outputPrinter->newLine(); } } diff --git a/src/Compare/Comparator/PHPStanExtensionsComparator.php b/src/Compare/Comparator/PHPStanExtensionsComparator.php index d742f95..5ad6c06 100644 --- a/src/Compare/Comparator/PHPStanExtensionsComparator.php +++ b/src/Compare/Comparator/PHPStanExtensionsComparator.php @@ -4,15 +4,15 @@ namespace Rector\Monitor\Compare\Comparator; +use Entropy\Console\Output\OutputPrinter; use Rector\Monitor\Compare\Contract\ComparatorInterface; use Rector\Monitor\Compare\Enum\PackageNames; use Rector\Monitor\Compare\ValueObject\ProjectMetadata; -use Symfony\Component\Console\Style\SymfonyStyle; final readonly class PHPStanExtensionsComparator implements ComparatorInterface { public function __construct( - private SymfonyStyle $symfonyStyle + private OutputPrinter $outputPrinter ) { } @@ -25,7 +25,7 @@ public function compare( return; } - $this->symfonyStyle->title('PHPStan extensions differences'); + $this->outputPrinter->title('PHPStan extensions differences'); $monorepoPHPStanPackageNames = $monorepoProjectMetadata->getPackagesByNames(PackageNames::PHPSTAN_EXTENSIONS); $mergePHPStanPackageNames = $mergeProjectMetadata->getPackagesByNames(PackageNames::PHPSTAN_EXTENSIONS); @@ -34,7 +34,7 @@ public function compare( $mergeExtraPackages = array_diff($mergePHPStanPackageNames, $monorepoPHPStanPackageNames); if ($monorepoExtraPackages === [] && $mergeExtraPackages === []) { - $this->symfonyStyle->success('No differences found'); + $this->outputPrinter->greenBackground('No differences found'); return; } @@ -57,13 +57,13 @@ public function compare( */ private function renderPackages(string $title, array $extraPackages): void { - $this->symfonyStyle->writeln($title); + $this->outputPrinter->writeln($title); - $this->symfonyStyle->newLine(); - $this->symfonyStyle->listing($extraPackages); + $this->outputPrinter->newLine(); + $this->outputPrinter->listing($extraPackages); - $this->symfonyStyle->writeln('Add them via this command to the monorepo project:'); - $this->symfonyStyle->writeln('composer require --dev ' . implode(' ', $extraPackages)); - $this->symfonyStyle->newLine(); + $this->outputPrinter->green('Add them via this command to the monorepo project:'); + $this->outputPrinter->writeln('composer require --dev ' . implode(' ', $extraPackages)); + $this->outputPrinter->newLine(); } } diff --git a/src/Compare/Comparator/PHPStanLevelComparator.php b/src/Compare/Comparator/PHPStanLevelComparator.php index 8d2fcbd..02c07e3 100644 --- a/src/Compare/Comparator/PHPStanLevelComparator.php +++ b/src/Compare/Comparator/PHPStanLevelComparator.php @@ -4,14 +4,14 @@ namespace Rector\Monitor\Compare\Comparator; +use Entropy\Console\Output\OutputPrinter; use Rector\Monitor\Compare\Contract\ComparatorInterface; use Rector\Monitor\Compare\ValueObject\ProjectMetadata; -use Symfony\Component\Console\Style\SymfonyStyle; final readonly class PHPStanLevelComparator implements ComparatorInterface { public function __construct( - private SymfonyStyle $symfonyStyle + private OutputPrinter $outputPrinter, ) { } @@ -24,29 +24,29 @@ public function compare( return; } - $this->symfonyStyle->title('PHPStan level differences'); + $this->outputPrinter->title('PHPStan level differences'); $monorepoPHPStanLevel = $monorepoProjectMetadata->getPHPStanConfig()['parameters']['level'] ?? null; $mergePHPStanLeve = $mergeProjectMetadata->getPHPStanConfig()['parameters']['level'] ?? null; if ($monorepoPHPStanLevel === $mergePHPStanLeve) { - $this->symfonyStyle->success('No differences in PHPStan levels found'); + $this->outputPrinter->greenBackground('No differences in PHPStan levels found'); return; } - $this->symfonyStyle->writeln('Different PHPStan levels found:'); - $this->symfonyStyle->newLine(); - $this->symfonyStyle->writeln(sprintf( + $this->outputPrinter->writeln('Different PHPStan levels found:'); + $this->outputPrinter->newLine(); + $this->outputPrinter->writeln(sprintf( ' * monorepo project ("%s"): level %s', $monorepoProjectMetadata->getName(), (string) $monorepoPHPStanLevel )); - $this->symfonyStyle->writeln(sprintf( + $this->outputPrinter->writeln(sprintf( ' * merge project ("%s"): level %s', $mergeProjectMetadata->getName(), (string) $mergePHPStanLeve )); - $this->symfonyStyle->newLine(); + $this->outputPrinter->newLine(); } } diff --git a/src/Compare/Comparator/PHPStanPathsComparator.php b/src/Compare/Comparator/PHPStanPathsComparator.php index 506439d..0aea14a 100644 --- a/src/Compare/Comparator/PHPStanPathsComparator.php +++ b/src/Compare/Comparator/PHPStanPathsComparator.php @@ -4,14 +4,14 @@ namespace Rector\Monitor\Compare\Comparator; +use Entropy\Console\Output\OutputPrinter; use Rector\Monitor\Compare\Contract\ComparatorInterface; use Rector\Monitor\Compare\ValueObject\ProjectMetadata; -use Symfony\Component\Console\Style\SymfonyStyle; final readonly class PHPStanPathsComparator implements ComparatorInterface { public function __construct( - private SymfonyStyle $symfonyStyle + private OutputPrinter $outputPrinter, ) { } @@ -24,7 +24,7 @@ public function compare( return; } - $this->symfonyStyle->title('PHPStan paths differences'); + $this->outputPrinter->title('PHPStan paths differences'); $monorepoPHPStanConfig = $monorepoProjectMetadata->getPHPStanConfig(); $mergePHPStanConfig = $mergeProjectMetadata->getPHPStanConfig(); @@ -33,7 +33,7 @@ public function compare( $mergePaths = $mergePHPStanConfig['parameters']['paths'] ?? []; if ($monorepoPaths === [] || $mergePaths === []) { - $this->symfonyStyle->writeln( + $this->outputPrinter->writeln( 'One of the projects does not have PHPStan paths configured. Unable to compare' ); return; @@ -43,7 +43,7 @@ public function compare( $mergeExtraPaths = array_diff($mergePaths, $monorepoPaths); if ($monorepoExtraPaths === [] && $mergeExtraPaths === []) { - $this->symfonyStyle->success('No differences found'); + $this->outputPrinter->greenBackground('No differences found'); return; } @@ -69,11 +69,11 @@ public function compare( */ private function renderPaths(string $title, array $extraPaths): void { - $this->symfonyStyle->writeln($title); + $this->outputPrinter->writeln($title); - $this->symfonyStyle->newLine(); - $this->symfonyStyle->listing($extraPaths); + $this->outputPrinter->newLine(); + $this->outputPrinter->listing($extraPaths); - $this->symfonyStyle->newLine(); + $this->outputPrinter->newLine(); } } diff --git a/src/Compare/Comparator/SymfonyConfigFilesComparator.php b/src/Compare/Comparator/SymfonyConfigFilesComparator.php index 6798482..7af1477 100644 --- a/src/Compare/Comparator/SymfonyConfigFilesComparator.php +++ b/src/Compare/Comparator/SymfonyConfigFilesComparator.php @@ -4,14 +4,14 @@ namespace Rector\Monitor\Compare\Comparator; +use Entropy\Console\Output\OutputPrinter; use Rector\Monitor\Compare\Contract\ComparatorInterface; use Rector\Monitor\Compare\ValueObject\ProjectMetadata; -use Symfony\Component\Console\Style\SymfonyStyle; final readonly class SymfonyConfigFilesComparator implements ComparatorInterface { public function __construct( - private SymfonyStyle $symfonyStyle + private OutputPrinter $outputPrinter, ) { } @@ -28,18 +28,18 @@ public function compare( return; } - $this->symfonyStyle->title('Comparing Symfony config directories'); + $this->outputPrinter->title('Comparing Symfony config directories'); if ($monorepoConfigDirectory !== $mergeConfigDirectory) { - $this->symfonyStyle->warning('Projects have different /config directory location'); + $this->outputPrinter->orangeBackground('Projects have different /config directory location'); - $this->symfonyStyle->writeln(sprintf( + $this->outputPrinter->writeln(sprintf( '%s: %s', $monorepoProjectMetadata->getName(), $monorepoProjectMetadata->getConfigDirectory(), )); - $this->symfonyStyle->writeln(sprintf( + $this->outputPrinter->writeln(sprintf( '%s: %s', $mergeProjectMetadata->getName(), $mergeProjectMetadata->getConfigDirectory(), @@ -58,20 +58,20 @@ public function compare( $extraMergeConfigFiles = array_diff($mergeProjectMetadata->getConfigFiles(), $commonConfigFiles); - $this->symfonyStyle->writeln(sprintf( + $this->outputPrinter->writeln(sprintf( 'Extra monorepo project config files ("%s"):', $monorepoProjectMetadata->getName() )); - $this->symfonyStyle->newLine(); - $this->symfonyStyle->listing($extraMonorepoConfigFiles); - $this->symfonyStyle->newLine(); + $this->outputPrinter->newLine(); + $this->outputPrinter->listing($extraMonorepoConfigFiles); + $this->outputPrinter->newLine(); - $this->symfonyStyle->writeln(sprintf( + $this->outputPrinter->writeln(sprintf( 'Extra merge project config files ("%s"):', $mergeProjectMetadata->getName() )); - $this->symfonyStyle->newLine(); - $this->symfonyStyle->listing($extraMergeConfigFiles); + $this->outputPrinter->newLine(); + $this->outputPrinter->listing($extraMergeConfigFiles); } } } diff --git a/src/Compare/Enum/PackageNames.php b/src/Compare/Enum/PackageNames.php index 69434e5..549b736 100644 --- a/src/Compare/Enum/PackageNames.php +++ b/src/Compare/Enum/PackageNames.php @@ -9,7 +9,7 @@ final class PackageNames /** * @var string[] */ - public const PHPSTAN_EXTENSIONS = [ + public const array PHPSTAN_EXTENSIONS = [ 'phpstan/phpstan-doctrine', 'phpstan/phpstan-deprecation-rules', 'phpstan/phpstan-symfony', diff --git a/src/Compare/Utils/JsonLoader.php b/src/Compare/Utils/JsonLoader.php deleted file mode 100644 index 7e1ad4d..0000000 --- a/src/Compare/Utils/JsonLoader.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - public static function loadFileToJson(string $filePath): array - { - Assert::fileExists($filePath); - $fileContents = FileSystem::read($filePath); - - $json = Json::decode($fileContents, forceArrays: true); - Assert::isArray($json); - - return $json; - } -} diff --git a/src/Compare/ValueObject/ProjectMetadata.php b/src/Compare/ValueObject/ProjectMetadata.php index 01d84da..8ad1461 100644 --- a/src/Compare/ValueObject/ProjectMetadata.php +++ b/src/Compare/ValueObject/ProjectMetadata.php @@ -4,10 +4,8 @@ namespace Rector\Monitor\Compare\ValueObject; +use Entropy\Utils\FileSystem; use Nette\Neon\Neon; -use Nette\Utils\FileSystem; -use Nette\Utils\Strings; -use Rector\Monitor\Compare\Utils\JsonLoader; use RuntimeException; use Symfony\Component\Finder\Finder; use Throwable; @@ -26,7 +24,7 @@ public function __construct( */ public function getComposerJson(): array { - return JsonLoader::loadFileToJson($this->projectDirectory . '/composer.json'); + return FileSystem::loadFileToJson($this->projectDirectory . '/composer.json'); } /** @@ -55,7 +53,7 @@ public function getPackagesByNames(array $packageNames): array public function getName(): string { - return (string) Strings::after($this->projectDirectory, '/', -1); + return basename($this->projectDirectory); } public function getConfigDirectory(): ?string diff --git a/src/Config/ConfigInitializer.php b/src/Config/ConfigInitializer.php index 726ff76..e370f0c 100644 --- a/src/Config/ConfigInitializer.php +++ b/src/Config/ConfigInitializer.php @@ -4,13 +4,13 @@ namespace Rector\Monitor\Config; -use Symfony\Component\Console\Style\SymfonyStyle; +use Entropy\Console\Output\OutputPrinter; use Symfony\Component\Filesystem\Filesystem; final readonly class ConfigInitializer { public function __construct( - private SymfonyStyle $symfonyStyle, + private OutputPrinter $outputPrinter, private Filesystem $filesystem, ) { } @@ -22,19 +22,11 @@ public function createConfigIfMissing(string $projectDirectory): bool return false; } - $response = $this->symfonyStyle->ask('No "monitor.php" config found. Should we generate it for you?', 'yes'); - - // be tolerant about input - if (! in_array($response, ['yes', 'YES', 'y', 'Y'], true)) { - // okay, nothing we can do - return false; - } - $templateFileContents = $this->filesystem->readFile(__DIR__ . '/../../templates/monitor.php.dist'); // create the ecs.php file $this->filesystem->dumpFile(getcwd() . '/monitor.php', $templateFileContents); - $this->symfonyStyle->success( + $this->outputPrinter->greenBackground( 'The monitor.php config was generated! Fill your repositories details and re-run the command again' ); diff --git a/src/Console/Command/CleanListCommand.php b/src/Console/Command/CleanListCommand.php deleted file mode 100644 index c61ec4e..0000000 --- a/src/Console/Command/CleanListCommand.php +++ /dev/null @@ -1,71 +0,0 @@ -getApplication(), Application::class); - - $output->writeln($this->getApplication()->getName()); - $output->writeln(''); - $output->writeln('Available commands:'); - - $applicationDescription = new ApplicationDescription($this->getApplication()); - $this->describeCommands($applicationDescription, $output); - - $output->writeln(''); - - return self::SUCCESS; - } - - /** - * @param non-empty-array $commands - */ - private function resolveCommandNameColumnWidth(array $commands): int - { - $commandNameLengths = []; - foreach ($commands as $command) { - $commandNameLengths[] = strlen((string) $command->getName()); - } - - return max($commandNameLengths) + 4; - } - - private function describeCommands(ApplicationDescription $applicationDescription, OutputInterface $output): void - { - if ($applicationDescription->getCommands() === []) { - return; - } - - $commands = $applicationDescription->getCommands(); - $commandNameColumnWidth = $this->resolveCommandNameColumnWidth($commands); - - foreach ($commands as $command) { - $spacingWidth = $commandNameColumnWidth - strlen((string) $command->getName()); - - $output->writeln(sprintf( - ' %s%s%s', - $command->getName(), - str_repeat(' ', $spacingWidth), - $command->getDescription() - )); - } - } -} diff --git a/src/Console/MonitorConsoleApplication.php b/src/Console/MonitorConsoleApplication.php deleted file mode 100644 index 727fa45..0000000 --- a/src/Console/MonitorConsoleApplication.php +++ /dev/null @@ -1,28 +0,0 @@ -service(MonitorConsoleApplication::class, function ( - Container $container - ): MonitorConsoleApplication { - $monitorConsoleApplication = new MonitorConsoleApplication('Rector Monitor'); + $container->autodiscover(__DIR__ . '/../Analyze/RuleProcessor'); + $container->autodiscover(__DIR__ . '/../Compare/Comparator'); - $commands = $container->findByContract(Command::class); - $monitorConsoleApplication->addCommands($commands); - - // remove basic command to make output clear - $this->hideDefaultCommands($monitorConsoleApplication); - - return $monitorConsoleApplication; - }); - - $container->service( - CompareProjectsCommand::class, - function (Container $container): CompareProjectsCommand { - return new CompareProjectsCommand( - $container->make(SymfonyStyle::class), - $container->findByContract(ComparatorInterface::class) - ); - } - ); - - $container->service( - SymfonyStyle::class, - static fn (): SymfonyStyle => new SymfonyStyle(new ArrayInput([]), new ConsoleOutput()) - ); + $container->autodiscover(__DIR__ . '/../Analyze/Command'); + $container->autodiscover(__DIR__ . '/../Compare/Command'); + $container->autodiscover(__DIR__ . '/../Matrix/Command'); return $container; } - - public function hideDefaultCommands(Application $application): void - { - $application->get('list') - ->setHidden(true); - $application->get('completion') - ->setHidden(true); - $application->get('help') - ->setHidden(true); - } } diff --git a/src/Entropy/ConsoleTable.php b/src/Entropy/ConsoleTable.php new file mode 100644 index 0000000..c61af9f --- /dev/null +++ b/src/Entropy/ConsoleTable.php @@ -0,0 +1,173 @@ + + */ + private readonly array $headers; + + /** + * @var list> + */ + private array $rows = []; + + /** + * @var list<'left'|'right'|'center'> + */ + private array $align; + + /** + * @param string[] $headers + * @param array> $rows + * @param array<'left'|'right'|'center'> $columnsAlign + */ + public function __construct(array $headers, array $rows, array $columnsAlign = []) + { + Assert::notEmpty($headers); + Assert::allString($headers); + + // nested arrays + Assert::notEmpty($rows); + Assert::allIsArray($rows); + + $this->headers = array_values($headers); + foreach ($rows as $row) { + $this->rows[] = array_values( + array_map( + static fn (float|int|TableCell|string|null $value): string => $value === null ? '' : (string) $value, + $row + ) + ); + } + + $this->align = array_values($columnsAlign); + } + + public function render(): string + { + $cols = max(count($this->headers), $this->maxRowColumns()); + $columnWidths = array_fill(0, $cols, 0); + + // headers + foreach ($this->headers as $columId => $header) { + $columnWidths[$columId] = max($columnWidths[$columId], $this->strlenVisible($header)); + } + + // rows + foreach ($this->rows as $row) { + foreach ($row as $columnId => $cell) { + $columnWidths[$columnId] = max($columnWidths[$columnId], $this->strlenVisible($cell)); + } + } + + $output = []; + + // header + $output[] = $this->line($columnWidths); + + $styledHeaders = []; + foreach ($this->headers as $header) { + $styledHeaders[] = new TableCell($header, 'green'); + } + + $output[] = $this->rowLine($styledHeaders, $columnWidths); + + // rows + $output[] = $this->line($columnWidths); + foreach ($this->rows as $row) { + $output[] = $this->rowLine($row, $columnWidths); + } + + $output[] = $this->line($columnWidths); + + return implode(PHP_EOL, $output) . PHP_EOL; + } + + private function maxRowColumns(): int + { + $max = 0; + foreach ($this->rows as $row) { + $max = max($max, count($row)); + } + + return $max; + } + + /** + * @param list $widths + */ + private function line(array $widths): string + { + $result = ' '; + + foreach ($widths as $width) { + $result .= str_repeat('-', $width + 2) . '-'; + } + + return $result; + } + + /** + * Paints full row line + * + * @param list $row + * @param list $widths + */ + private function rowLine(array $row, array $widths): string + { + $result = ' '; + + foreach ($widths as $columnKey => $width) { + $value = $row[$columnKey] ?? ''; + + $align = $this->align[$columnKey] ?? ColumnAlign::LEFT; + $result .= ' ' . $this->padVisible($value, $width, $align) . ' |'; + } + + return $result; + } + + private function strlenVisible(string|Stringable $contents): int + { + $string = (string) preg_replace('#\e\[[0-9;]*[A-Za-z]#', '', (string) $contents); + + if (function_exists('mb_strlen')) { + return mb_strlen($string); + } + + return strlen($string); + } + + /** + * @param ColumnAlign::* $columnAllign + */ + private function padVisible(string|TableCell $content, int $width, string $columnAllign): string + { + $length = $this->strlenVisible($content); + + $padding = max(0, $width - $length); + + return match ($columnAllign) { + ColumnAlign::LEFT => $content . str_repeat(' ', $padding), + ColumnAlign::RIGHT => str_repeat(' ', $padding) . $content, + ColumnAlign::CENTER => str_repeat(' ', intdiv($padding, 2)) . $content . str_repeat( + ' ', + $padding - intdiv($padding, 2) + ), + }; + } +} diff --git a/src/Entropy/Enum/ColumnAlign.php b/src/Entropy/Enum/ColumnAlign.php new file mode 100644 index 0000000..f3d8569 --- /dev/null +++ b/src/Entropy/Enum/ColumnAlign.php @@ -0,0 +1,14 @@ +content; + + if ($this->color !== null) { + $styledContent = $outputColorizer->color($styledContent, $this->color); + } + + if ($this->background !== null) { + return $outputColorizer->background($styledContent, $this->background); + } + + return $styledContent; + } +} diff --git a/src/Git/RepositoryMetafilesResolver.php b/src/Git/RepositoryMetafilesResolver.php index aa203e7..66b50f3 100644 --- a/src/Git/RepositoryMetafilesResolver.php +++ b/src/Git/RepositoryMetafilesResolver.php @@ -4,12 +4,12 @@ namespace Rector\Monitor\Git; -use Nette\Utils\FileSystem; +use Entropy\Console\Output\OutputPrinter; +use Entropy\Utils\FileSystem; use Rector\Monitor\Exception\ShouldNotHappenException; use Rector\Monitor\ValueObject\Repository; use Rector\Monitor\ValueObject\RepositoryCollection; use Rector\Monitor\ValueObjectFactory\ComposerJsonFactory; -use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\SplFileInfo; use Symfony\Component\Process\Process; @@ -18,14 +18,14 @@ { public function __construct( private ComposerJsonFactory $composerJsonFactory, - private SymfonyStyle $symfonyStyle + private OutputPrinter $outputPrinter ) { } public function decorateRepositories( RepositoryCollection $repositoryCollection, - bool $clearCache = false, - bool $isDebug = false + bool $clearCache, + bool $isDebug ): void { foreach ($repositoryCollection->all() as $repository) { $repositoryCacheDirectory = sys_get_temp_dir() . '/rector-monitor-cache/repository-' . md5( @@ -38,14 +38,16 @@ public function decorateRepositories( } if ($this->hasDirectorySomeFiles($repositoryCacheDirectory)) { - $this->symfonyStyle->writeln(sprintf( - ' * loading "%s" repository from cache', - $repository->getRepositoryUrl() - )); + if ($isDebug) { + $this->outputPrinter->writeln(sprintf( + 'Loading "%s" repository from cache', + $repository->getRepositoryUrlWithoutLink() + )); + } } else { - $this->symfonyStyle->writeln(sprintf( - ' * loading files from "%s" remote repository', - $repository->getRepositoryUrl() + $this->outputPrinter->writeln(sprintf( + 'Loading files from "%s" remote repository', + $repository->getRepositoryUrlWithoutLink() )); $this->downloadRepository($repository, $repositoryCacheDirectory, $isDebug); @@ -73,41 +75,29 @@ private function findRootFilesInDirectory(string $repositoryCacheDirectory): arr private function downloadRepository(Repository $repository, string $repositoryCacheDirectory, bool $isDebug): void { - FileSystem::createDir($repositoryCacheDirectory); - - $process = Process::fromShellCommandline('git init', $repositoryCacheDirectory); - if ($isDebug) { - $this->symfonyStyle->writeln('Running command: ' . $process->getCommandLine()); - } - - $process->mustRun(); - if ($isDebug) { - $this->symfonyStyle->writeln('Cloning repository:' . PHP_EOL . $process->getOutput()); - } - - $process = Process::fromShellCommandline( - 'git remote add origin ' . $repository->getClonableRepositoryUrl(), - $repositoryCacheDirectory - ); - + FileSystem::ensureDirectoryExists($repositoryCacheDirectory); + + $gitCloneProcess = new Process([ + 'git', + 'clone', + $repository->getClonableRepositoryUrl(), + '.', + '--depth', + 1, + ], $repositoryCacheDirectory); if ($isDebug) { - $this->symfonyStyle->writeln('Running command: ' . $process->getCommandLine()); + $this->outputPrinter->writeln('Cloning repository to cache: ' . $gitCloneProcess->getCommandLine()); } - $process->mustRun(); - if ($isDebug) { - $this->symfonyStyle->writeln('Cloning repository:' . PHP_EOL . $process->getOutput()); - } - - // might be a bit longer for large repositories - Process::fromShellCommandline('git fetch origin --depth 1', $repositoryCacheDirectory, timeout: 120) - ->mustRun(); + $gitCloneProcess->mustRun(); // Fetch the latest changes from the remote repository - Process::fromShellCommandline( - sprintf('git checkout %s', $repository->getBranch() ?: 'main'), - $repositoryCacheDirectory - )->mustRun(); + $gitCheckoutBranchProcess = new Process([ + 'git', + 'checkout', + $repository->getBranch() ?: 'main', + ], cwd: $repositoryCacheDirectory); + $gitCheckoutBranchProcess->mustRun(); } /** @@ -115,6 +105,13 @@ private function downloadRepository(Repository $repository, string $repositoryCa */ private function matchComposerJsonFile(array $rootFiles, Repository $repository): SplFileInfo { + if ($rootFiles === []) { + throw new ShouldNotHappenException(sprintf( + 'No root files found for %s repository. Are you sure its cloned propperly? Re-run with "--clear-cache" to download again', + $repository->getRepositoryUrlWithoutLink(), + )); + } + foreach ($rootFiles as $rootFile) { if ($rootFile->getBasename() !== 'composer.json') { continue; diff --git a/src/Helper/JsonFileSystem.php b/src/Helper/JsonFileSystem.php deleted file mode 100644 index ffe6a5e..0000000 --- a/src/Helper/JsonFileSystem.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ - public static function readFilePath(string $filePath): array - { - $fileContents = FileSystem::read($filePath); - return Json::decode($fileContents, \true); - } -} diff --git a/src/Helper/SymfonyColumnStyler.php b/src/Helper/SymfonyColumnStyler.php index f529074..94fbcc6 100644 --- a/src/Helper/SymfonyColumnStyler.php +++ b/src/Helper/SymfonyColumnStyler.php @@ -5,8 +5,7 @@ namespace Rector\Monitor\Helper; use Composer\Semver\Comparator; -use Symfony\Component\Console\Helper\TableCell; -use Symfony\Component\Console\Helper\TableCellStyle; +use Rector\Monitor\Entropy\TableCell; /** * @see https://symfony.com/doc/current/components/console/helpers/table.html @@ -47,52 +46,14 @@ public static function styleHighsAndLows(array $tableRow): array } if ($value === $highValue) { - return self::createGreenTextCell($value); + return new TableCell($value, 'green'); } if ($value === $lowValue) { - return self::createRedTextCell($value); + return new TableCell($value, 'red'); } return $value; }, $tableRow); } - - public static function createRedCell(string $content): TableCell - { - return self::cellWithStyle($content, [ - 'align' => 'center', - 'bg' => 'red', - 'fg' => 'white', - ]); - } - - private static function createRedTextCell(string $content): TableCell - { - return self::cellWithStyle($content, [ - 'align' => 'right', - 'fg' => 'red', - ]); - } - - private static function createGreenTextCell(string $content): TableCell - { - return self::cellWithStyle($content, [ - 'align' => 'right', - 'fg' => 'green', - ]); - } - - /** - * @param array $styleOptions - */ - private static function cellWithStyle(string $content, array $styleOptions): TableCell - { - - $tableCellStyle = new TableCellStyle($styleOptions); - - return new TableCell($content, [ - 'style' => $tableCellStyle, - ]); - } } diff --git a/src/Matrix/Command/MatrixCommand.php b/src/Matrix/Command/MatrixCommand.php index 53b6e1e..788ee2a 100644 --- a/src/Matrix/Command/MatrixCommand.php +++ b/src/Matrix/Command/MatrixCommand.php @@ -4,80 +4,78 @@ namespace Rector\Monitor\Matrix\Command; -use Nette\Utils\Strings; +use Entropy\Console\Contract\CommandInterface; +use Entropy\Console\Enum\Color; +use Entropy\Console\Enum\ExitCode; +use Entropy\Console\Output\OutputPrinter; use Rector\Monitor\Config\ConfigInitializer; use Rector\Monitor\Config\MonitorConfigProvider; +use Rector\Monitor\Entropy\ConsoleTable; +use Rector\Monitor\Entropy\Enum\ColumnAlign; +use Rector\Monitor\Entropy\TableCell; use Rector\Monitor\Git\RepositoryMetafilesResolver; use Rector\Monitor\Helper\SymfonyColumnStyler; use Rector\Monitor\Matrix\Composer\PackageSorter; use Rector\Monitor\ValueObject\RepositoryCollection; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\TableStyle; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; - -final class MatrixCommand extends Command + +final readonly class MatrixCommand implements CommandInterface { private const string MISSING_LABEL = '*MISSING*'; public function __construct( - private readonly SymfonyStyle $symfonyStyle, - private readonly MonitorConfigProvider $monitorConfigProvider, - private readonly PackageSorter $packageSorter, - private readonly RepositoryMetafilesResolver $repositoryMetafilesResolver, - private readonly ConfigInitializer $configInitializer + private OutputPrinter $outputPrinter, + private MonitorConfigProvider $monitorConfigProvider, + private PackageSorter $packageSorter, + private RepositoryMetafilesResolver $repositoryMetafilesResolver, + private ConfigInitializer $configInitializer ) { - parent::__construct(); - } - - protected function configure(): void - { - $this->setName('matrix'); - - $this->setDescription('Load repositories and show their dependency differences'); - - $this->addOption( - 'clear-cache', - null, - InputOption::VALUE_NONE, - 'Remove cached repositories composer.json files' - ); - - // add --debug option - $this->addOption('debug', null, InputOption::VALUE_NONE, 'Enable debug mode for more verbose output'); } - protected function execute(InputInterface $input, OutputInterface $output): int + /** + * @param bool $clearCache Remove cached repositories composer.json files + * @param bool $debug Enable debug mode for more verbose output + * + * @return ExitCode::* + */ + public function run(bool $clearCache = false, bool $debug = false): int { if ($this->configInitializer->createConfigIfMissing(getcwd())) { - return self::SUCCESS; + return ExitCode::SUCCESS; } $monitorConfig = $this->monitorConfigProvider->provide(); - $shouldClearCache = (bool) $input->getOption('clear-cache'); - $isDebug = (bool) $input->getOption('debug'); - $this->symfonyStyle->title('Composer Dependency Matrix'); + $this->outputPrinter->title('Composer Dependency Matrix'); $this->repositoryMetafilesResolver->decorateRepositories( $monitorConfig->getRepositoryCollection(), - $shouldClearCache, - $isDebug + $clearCache, + $debug ); - $this->symfonyStyle->newLine(2); - $repositoryCollection = $monitorConfig->getRepositoryCollection(); $requiredPackageNames = $monitorConfig->getComposerRequiredPackageNames(); - $tableHeadlines = array_merge(['dependency'], $repositoryCollection->getRepositoryNamesByPackageCount()); + $tableHeadlines = array_merge( + ['dependency ↓ \ version →'], + $repositoryCollection->getRepositoryNamesSortedByPackageCount() + ); + $tableRows = $this->createTableRows($requiredPackageNames, $repositoryCollection); $this->renderTable($tableHeadlines, $tableRows); - return self::SUCCESS; + return ExitCode::SUCCESS; + } + + public function getName(): string + { + return 'matrix'; + } + + public function getDescription(): string + { + return 'Load repositories and show their required packages diff in beautiful colored table'; } /** @@ -104,7 +102,7 @@ private function createTableRows(array $requiredPackageNames, RepositoryCollecti } if ($this->isUnknownPhp($requiredPackageName, $packageVersion)) { - $dataRow[] = SymfonyColumnStyler::createRedCell(self::MISSING_LABEL); + $dataRow[] = new TableCell(self::MISSING_LABEL, null, Color::RED); } else { $dataRow[] = $packageVersion; } @@ -116,7 +114,12 @@ private function createTableRows(array $requiredPackageNames, RepositoryCollecti } $dataRow = SymfonyColumnStyler::styleHighsAndLows($dataRow); - $shortRequiredPackageName = Strings::truncate($requiredPackageName, 35); + + if (strlen((string) $requiredPackageName) > 35) { + $shortRequiredPackageName = substr((string) $requiredPackageName, 0, 35) . '...'; + } else { + $shortRequiredPackageName = $requiredPackageName; + } $tableRow = array_merge([$shortRequiredPackageName], $dataRow); $tableRows[] = $tableRow; @@ -127,24 +130,19 @@ private function createTableRows(array $requiredPackageNames, RepositoryCollecti /** * @param string[] $tableHeadlines - * @param mixed[] $tableRows + * @param array> $tableRows */ private function renderTable(array $tableHeadlines, array $tableRows): void { - $table = $this->symfonyStyle->createTable() - ->setHeaders($tableHeadlines) - ->setRows($tableRows); - // align number to right to - $counter = count($tableHeadlines); - - // align number to right to - for ($i = 1; $i < $counter; ++$i) { - $table->setColumnStyle($i, (new TableStyle())->setPadType(STR_PAD_LEFT)); - } + // create array of "right", in the same count as count of $tableHeadlines + $alligns = array_fill(0, count($tableHeadlines), ColumnAlign::CENTER); + + // allign first column to left + array_unshift($alligns, ColumnAlign::LEFT); - $table->render(); + $consoleTable = new ConsoleTable($tableHeadlines, $tableRows, $alligns); - $this->symfonyStyle->newLine(); + $this->outputPrinter->writeln($consoleTable->render()); } private function isUnknownPhp(string $packageName, ?string $packageVersion): bool diff --git a/src/ValueObject/ComposerJson.php b/src/ValueObject/ComposerJson.php index 32718cf..55f51f4 100644 --- a/src/ValueObject/ComposerJson.php +++ b/src/ValueObject/ComposerJson.php @@ -4,7 +4,7 @@ namespace Rector\Monitor\ValueObject; -use Nette\Utils\Strings; +use Entropy\Utils\Regex; use Webmozart\Assert\Assert; final readonly class ComposerJson @@ -20,9 +20,8 @@ public function __construct( public function getRepositoryName(): string { - $match = Strings::match($this->repositoryGit, '#(?[^/]+)\.git$#'); + $match = Regex::match($this->repositoryGit, '#(?[^/]+)\.git$#'); - Assert::isArray($match); Assert::keyExists($match, 'repository_name'); return $match['repository_name']; diff --git a/src/ValueObject/Repository.php b/src/ValueObject/Repository.php index f8b6b67..b5dffb4 100644 --- a/src/ValueObject/Repository.php +++ b/src/ValueObject/Repository.php @@ -4,7 +4,7 @@ namespace Rector\Monitor\ValueObject; -use Rector\Monitor\Helper\JsonFileSystem; +use Entropy\Utils\FileSystem; use Symfony\Component\Finder\SplFileInfo; use Webmozart\Assert\Assert; @@ -31,6 +31,15 @@ public function getRepositoryUrl(): string return $this->repositoryUrl; } + public function getRepositoryUrlWithoutLink(): string + { + // remove starting "https://" or "git@", to enable colors and avoid terminal plugin blacking text + $cleanRepositoryUrl = preg_replace('#^https://|^git@#', '', $this->repositoryUrl); + Assert::string($cleanRepositoryUrl); + + return $cleanRepositoryUrl; + } + public function getClonableRepositoryUrl(): string { // in case of bitbucket auth, we'll need to use name + token to allow cloning private repositories mutually @@ -39,7 +48,7 @@ public function getClonableRepositoryUrl(): string return $this->repositoryUrl; } - $authJson = JsonFileSystem::readFilePath($bitbucketAuthJson); + $authJson = FileSystem::loadFileToJson($bitbucketAuthJson); // access bitbucket-oauth, bitbucket.org, consumer-key $username = $authJson['bitbucket-oauth']['bitbucket.org']['consumer-key'] ?? null; diff --git a/src/ValueObject/RepositoryCollection.php b/src/ValueObject/RepositoryCollection.php index db361a9..f8c967b 100644 --- a/src/ValueObject/RepositoryCollection.php +++ b/src/ValueObject/RepositoryCollection.php @@ -33,25 +33,10 @@ public function getComposerRequiredPackageNames(): array return $this->filterOutExtensions($uniquePackageNames); } - // /** - // * @return string[] - // */ - // public function getRepositoryNames(): array - // { - // $repositoryNames = []; - // foreach ($this->repositories as $repository) { - // $composerJson = $repository->getComposerJson(); - // - // $repositoryNames[] = $composerJson->getRepositoryName(); - // } - // - // return $repositoryNames; - // } - /** * @return string[] */ - public function getRepositoryNamesByPackageCount(): array + public function getRepositoryNamesSortedByPackageCount(): array { $repositoryNames = []; foreach ($this->allSorterByPackageCount() as $composerJson) { diff --git a/src/ValueObjectFactory/ComposerJsonFactory.php b/src/ValueObjectFactory/ComposerJsonFactory.php index 0220626..3efba81 100644 --- a/src/ValueObjectFactory/ComposerJsonFactory.php +++ b/src/ValueObjectFactory/ComposerJsonFactory.php @@ -4,7 +4,7 @@ namespace Rector\Monitor\ValueObjectFactory; -use Nette\Utils\Json; +use Entropy\Utils\Json; use Rector\Monitor\ValueObject\ComposerJson; use Rector\Monitor\ValueObject\Repository; use Symfony\Component\Finder\SplFileInfo; @@ -26,7 +26,7 @@ private function resolveBareComposerJson(string $composerJsonContents): array { Assert::notEmpty($composerJsonContents); - $projectsComposerJson = Json::decode($composerJsonContents, forceArrays: true); + $projectsComposerJson = Json::decode($composerJsonContents); // store only necessary data return [ diff --git a/tests/Entropy/ConsoleTableTest.php b/tests/Entropy/ConsoleTableTest.php new file mode 100644 index 0000000..d3e882c --- /dev/null +++ b/tests/Entropy/ConsoleTableTest.php @@ -0,0 +1,53 @@ +render(); + + $this->assertSame( + <<render(); + + $this->assertSame( + <<