diff --git a/composer.json b/composer.json index 1bd9901..6ee6316 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "require": { "php": ">=8.2", "composer/semver": "^3.4", - "illuminate/container": "^12.0", + "illuminate/container": "^12.14", "nette/utils": "^4.0", "symfony/console": "^6.4", "symfony/finder": "^7.2", @@ -67,3 +67,4 @@ + diff --git a/src/Command/OpenVersionsCommand.php b/src/Command/OpenVersionsCommand.php index 29db5c9..1b6ab8d 100644 --- a/src/Command/OpenVersionsCommand.php +++ b/src/Command/OpenVersionsCommand.php @@ -6,9 +6,8 @@ use Nette\Utils\FileSystem; use Nette\Utils\Json; -use Nette\Utils\Strings; use Rector\Jack\Composer\ComposerOutdatedResponseProvider; -use Rector\Jack\Composer\NextVersionResolver; +use Rector\Jack\ComposerProcessor\OpenVersionsComposerProcessor; use Rector\Jack\Enum\ComposerKey; use Rector\Jack\OutdatedComposerFactory; use Symfony\Component\Console\Command\Command; @@ -20,9 +19,9 @@ final class OpenVersionsCommand extends Command { public function __construct( - private readonly NextVersionResolver $nextVersionResolver, private readonly OutdatedComposerFactory $outdatedComposerFactory, private readonly ComposerOutdatedResponseProvider $composerOutdatedResponseProvider, + private readonly OpenVersionsComposerProcessor $openVersionsComposerProcessor, ) { parent::__construct(); } @@ -93,58 +92,40 @@ protected function execute(InputInterface $input, OutputInterface $output): int $composerJsonContents = FileSystem::read($composerJsonFilePath); - $outdatedPackages = $outdatedComposer->getPackagesShuffled($onlyDev, $packagePrefix); - - $openedPackageCount = 0; - foreach ($outdatedPackages as $outdatedPackage) { - $composerVersion = $outdatedPackage->getComposerVersion(); - - // already filled with open version - if (str_contains($composerVersion, '|')) { - continue; - } - - // convert composer version to next version - $nextVersion = $this->nextVersionResolver->resolve($outdatedPackage->getName(), $composerVersion); - $openedVersion = $composerVersion . '|' . $nextVersion; - - // replace using regex, to keep original composer.json format - $composerJsonContents = Strings::replace( - $composerJsonContents, - // find - sprintf('#"%s": "(.*?)"#', $outdatedPackage->getName()), - // replace - sprintf('"%s": "%s"', $outdatedPackage->getName(), $openedVersion) - ); - - $symfonyStyle->writeln(sprintf( - ' * Opened "%s" package to "%s" version', - $outdatedPackage->getName(), - $openedVersion - )); + $changedPackageVersionsResult = $this->openVersionsComposerProcessor->process( + $composerJsonContents, + $outdatedComposer, + $limit, + $onlyDev, + $packagePrefix + ); - ++$openedPackageCount; - if ($openedPackageCount >= $limit) { - // we've reached the limit, so we can stop - break; - } - } + $openedPackages = $changedPackageVersionsResult->getChangedPackageVersions(); + $changedComposerJson = $changedPackageVersionsResult->getComposerJsonContents(); if ($isDryRun === false) { // update composer.json file, only if no --dry-run - FileSystem::write($composerJsonFilePath, $composerJsonContents . PHP_EOL); + FileSystem::write($composerJsonFilePath, $changedComposerJson . PHP_EOL, null); } $symfonyStyle->success( sprintf( '%d packages %s opened up to the next nearest version.%s%s "composer update" to push versions up', - $openedPackageCount, + count($openedPackages), $isDryRun ? 'would be (is "--dry-run")' : 'were', PHP_EOL, $isDryRun ? 'Then you would run' : 'Now run' ) ); + foreach ($openedPackages as $openedPackage) { + $symfonyStyle->writeln(sprintf( + ' * Opened "%s" package to "%s" version', + $openedPackage->getPackageName(), + $openedPackage->getNewVersion() + )); + } + return self::SUCCESS; } } diff --git a/src/Command/RaiseToInstalledCommand.php b/src/Command/RaiseToInstalledCommand.php new file mode 100644 index 0000000..045f9af --- /dev/null +++ b/src/Command/RaiseToInstalledCommand.php @@ -0,0 +1,89 @@ +setName('raise-to-lock'); + + $this->setDescription( + 'Raise your version in "composer.json" to installed one to get the latest version available in any composer update' + ); + + $this->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Only show diff of "composer.json" changes, do not write the file' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $symfonyStyle = new SymfonyStyle($input, $output); + $isDryRun = (bool) $input->getOption('dry-run'); + + $symfonyStyle->writeln('Analyzing "/vendor/composer/installed.json" for versions'); + + // load composer.json and replace versions in "require" and "require-dev", + $composerJsonFilePath = getcwd() . '/composer.json'; + + Assert::fileExists($composerJsonFilePath); + $composerJsonContents = FileSystem::read($composerJsonFilePath); + + $raiseToInstalledResult = $this->raiseToInstalledComposerProcessor->process($composerJsonContents); + + $changedPackages = $raiseToInstalledResult->getChangedPackageVersions(); + if ($changedPackages === []) { + $symfonyStyle->success('No changes made to "composer.json"'); + return self::SUCCESS; + } + + if ($isDryRun === false) { + $changedComposerJsonContents = $raiseToInstalledResult->getComposerJsonContents(); + FileSystem::write($composerJsonFilePath, $changedComposerJsonContents . PHP_EOL, null); + } + + $symfonyStyle->success(sprintf( + '%d package%s %s changed to installed versions.%s%s "composer update --lock" to update "composer.lock" hash', + count($changedPackages), + count($changedPackages) === 1 ? '' : 's', + $isDryRun ? 'would be (is "--dry-run")' : 'were updated', + PHP_EOL, + $isDryRun ? 'Then you would run' : 'Now run', + )); + + foreach ($changedPackages as $changedPackage) { + $symfonyStyle->writeln(sprintf( + ' * %s (%s => %s)', + $changedPackage->getPackageName(), + $changedPackage->getOldVersion(), + $changedPackage->getNewVersion() + )); + } + + $symfonyStyle->newLine(); + + return self::SUCCESS; + } +} diff --git a/src/Composer/InstalledVersionResolver.php b/src/Composer/InstalledVersionResolver.php new file mode 100644 index 0000000..c6a0af0 --- /dev/null +++ b/src/Composer/InstalledVersionResolver.php @@ -0,0 +1,32 @@ + + */ + public function resolve(): array + { + $installedJsonFilePath = getcwd() . '/vendor/composer/installed.json'; + + $installedJson = JsonFileLoader::loadFileToJson($installedJsonFilePath); + Assert::keyExists($installedJson, 'packages'); + + $installedPackagesToVersions = []; + foreach ($installedJson['packages'] as $installedPackage) { + $packageName = $installedPackage['name']; + $packageVersion = $installedPackage['version']; + + $installedPackagesToVersions[$packageName] = $packageVersion; + } + + return $installedPackagesToVersions; + } +} diff --git a/src/Composer/VersionComparator.php b/src/Composer/VersionComparator.php new file mode 100644 index 0000000..ebf1632 --- /dev/null +++ b/src/Composer/VersionComparator.php @@ -0,0 +1,15 @@ +getPackagesShuffled($onlyDev, $packagePrefix); + + $openedPackages = []; + + foreach ($outdatedPackages as $outdatedPackage) { + $composerVersion = $outdatedPackage->getComposerVersion(); + + // already filled with open version + if (str_contains($composerVersion, '|')) { + continue; + } + + // convert composer version to next version + $nextVersion = $this->nextVersionResolver->resolve($outdatedPackage->getName(), $composerVersion); + $openedVersion = $composerVersion . '|' . $nextVersion; + + // replace using regex, to keep original composer.json format + $composerJsonContents = ComposerJsonPackageVersionUpdater::update( + $composerJsonContents, + $outdatedPackage->getName(), + $openedVersion + ); + + $openedPackages[] = new ChangedPackageVersion( + $outdatedPackage->getName(), + $composerVersion, + $openedVersion, + ); + + if (count($openedPackages) >= $limit) { + // we've reached the limit, so we can stop + break; + } + } + + return new ChangedPackageVersionsResult($composerJsonContents, $openedPackages); + } +} diff --git a/src/ComposerProcessor/RaiseToInstalledComposerProcessor.php b/src/ComposerProcessor/RaiseToInstalledComposerProcessor.php new file mode 100644 index 0000000..a7ddb00 --- /dev/null +++ b/src/ComposerProcessor/RaiseToInstalledComposerProcessor.php @@ -0,0 +1,122 @@ +installedVersionResolver->resolve(); + + $composerJson = Json::decode($composerJsonContents, true); + + $changedPackageVersions = []; + + // iterate require and require-dev sections and check if installed version is newer one than in composer.json + // if so, replace it + foreach ($composerJson['require'] ?? [] as $packageName => $packageVersion) { + if (! isset($installedPackagesToVersions[$packageName])) { + continue; + } + + $installedVersion = $installedPackagesToVersions[$packageName]; + + // special case for unions + if (str_contains((string) $packageVersion, '|')) { + $passingVersionKeys = []; + + $unionPackageVersions = explode('|', (string) $packageVersion); + foreach ($unionPackageVersions as $key => $unionPackageVersion) { + $unionPackageConstraint = $this->versionParser->parseConstraints($unionPackageVersion); + + if (Comparator::greaterThanOrEqualTo( + $installedVersion, + $unionPackageConstraint->getLowerBound() + ->getVersion() + )) { + $passingVersionKeys[] = $key; + } + } + + // nothing we can do, as lower union version is passing + if ($passingVersionKeys === [0]) { + continue; + } + + // higher version is meet, let's drop the lower one + if ($passingVersionKeys === [0, 1]) { + $newPackageVersion = $unionPackageVersions[1]; + + $composerJsonContents = ComposerJsonPackageVersionUpdater::update( + $composerJsonContents, + $packageName, + $newPackageVersion + ); + + $changedPackageVersions[] = new ChangedPackageVersion( + $packageName, + $packageVersion, + $newPackageVersion + ); + continue; + } + } + + $normalizedInstalledVersion = $this->versionParser->normalize($installedVersion); + $installedPackageConstraint = $this->versionParser->parseConstraints($packageVersion); + + $normalizedConstraintVersion = $this->versionParser->normalize( + $installedPackageConstraint->getLowerBound() + ->getVersion() + ); + + // remove "-dev" suffix + $normalizedConstraintVersion = str_replace('-dev', '', $normalizedConstraintVersion); + + // are major + minor equal? + if (VersionComparator::areAndMinorVersionsEqual( + $normalizedConstraintVersion, + $normalizedInstalledVersion + )) { + continue; + } + + [$major, $minor, $patch] = explode('.', $normalizedInstalledVersion); + + $newRequiredVersion = sprintf('^%s.%s', $major, $minor); + + // lets update + $composerJsonContents = ComposerJsonPackageVersionUpdater::update( + $composerJsonContents, + $packageName, + $newRequiredVersion + ); + + // focus on minor only + // or on patch in case of 0.* + $changedPackageVersions[] = new ChangedPackageVersion($packageName, $packageVersion, $newRequiredVersion); + } + + return new ChangedPackageVersionsResult($composerJsonContents, $changedPackageVersions); + } +} diff --git a/src/FileSystem/ComposerJsonPackageVersionUpdater.php b/src/FileSystem/ComposerJsonPackageVersionUpdater.php new file mode 100644 index 0000000..7940a6d --- /dev/null +++ b/src/FileSystem/ComposerJsonPackageVersionUpdater.php @@ -0,0 +1,22 @@ + + */ + public static function loadFileToJson(string $filePath): array + { + Assert::fileExists($filePath); + + $fileContents = FileSystem::read($filePath); + + return Json::decode($fileContents, true); + } +} diff --git a/src/ValueObject/ChangedPackageVersion.php b/src/ValueObject/ChangedPackageVersion.php new file mode 100644 index 0000000..54dabee --- /dev/null +++ b/src/ValueObject/ChangedPackageVersion.php @@ -0,0 +1,31 @@ +packageName; + } + + public function getOldVersion(): string + { + return $this->oldVersion; + } + + public function getNewVersion(): string + { + return $this->newVersion; + } +} diff --git a/src/ValueObject/ComposerProcessorResult/ChangedPackageVersionsResult.php b/src/ValueObject/ComposerProcessorResult/ChangedPackageVersionsResult.php new file mode 100644 index 0000000..32de90e --- /dev/null +++ b/src/ValueObject/ComposerProcessorResult/ChangedPackageVersionsResult.php @@ -0,0 +1,34 @@ +composerJsonContents; + } + + /** + * @return ChangedPackageVersion[] + */ + public function getChangedPackageVersions(): array + { + return $this->changedPackageVersions; + } +} diff --git a/src/ValueObject/OutdatedComposer.php b/src/ValueObject/OutdatedComposer.php index 56e8b25..dc3b36b 100644 --- a/src/ValueObject/OutdatedComposer.php +++ b/src/ValueObject/OutdatedComposer.php @@ -4,6 +4,8 @@ namespace Rector\Jack\ValueObject; +use Webmozart\Assert\Assert; + final readonly class OutdatedComposer { /** @@ -12,6 +14,7 @@ public function __construct( private array $outdatedPackages ) { + Assert::allIsInstanceOf($outdatedPackages, OutdatedPackage::class); } public function getProdPackagesCount(): int diff --git a/tests/ComposerProcessor/OpenVersionsComposerProcessor/Fixture/expected-opened-composer.json b/tests/ComposerProcessor/OpenVersionsComposerProcessor/Fixture/expected-opened-composer.json new file mode 100644 index 0000000..a793a18 --- /dev/null +++ b/tests/ComposerProcessor/OpenVersionsComposerProcessor/Fixture/expected-opened-composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "symfony/console": "^5.4|6.0.*" + } +} diff --git a/tests/ComposerProcessor/OpenVersionsComposerProcessor/Fixture/some-closed-composer.json b/tests/ComposerProcessor/OpenVersionsComposerProcessor/Fixture/some-closed-composer.json new file mode 100644 index 0000000..d3f0cf5 --- /dev/null +++ b/tests/ComposerProcessor/OpenVersionsComposerProcessor/Fixture/some-closed-composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "symfony/console": "^5.4" + } +} diff --git a/tests/ComposerProcessor/OpenVersionsComposerProcessor/OpenVersionsComposerProcessorTest.php b/tests/ComposerProcessor/OpenVersionsComposerProcessor/OpenVersionsComposerProcessorTest.php new file mode 100644 index 0000000..05cf381 --- /dev/null +++ b/tests/ComposerProcessor/OpenVersionsComposerProcessor/OpenVersionsComposerProcessorTest.php @@ -0,0 +1,52 @@ +openVersionsComposerProcessor = $this->make(OpenVersionsComposerProcessor::class); + } + + public function test(): void + { + $composerJsonContents = FileSystem::read(__DIR__ . '/Fixture/some-closed-composer.json'); + + $outdatedComposer = new OutdatedComposer([ + new OutdatedPackage('symfony/console', '5.4.0', '^5.4', true, '6.4.0', '1 year'), + ]); + + $changedPackageVersionsResult = $this->openVersionsComposerProcessor->process( + $composerJsonContents, + $outdatedComposer, + 10, + false, + null + ); + + $this->assertCount(1, $changedPackageVersionsResult->getChangedPackageVersions()); + $this->assertContainsOnlyInstancesOf( + ChangedPackageVersion::class, + $changedPackageVersionsResult->getChangedPackageVersions() + ); + + $this->assertStringEqualsFile( + __DIR__ . '/Fixture/expected-opened-composer.json', + $changedPackageVersionsResult->getComposerJsonContents() + ); + } +} diff --git a/tests/ComposerProcessor/RaiseToInstalledComposerProcessor/Fixture/some-outdated-composer.json b/tests/ComposerProcessor/RaiseToInstalledComposerProcessor/Fixture/some-outdated-composer.json new file mode 100644 index 0000000..0659019 --- /dev/null +++ b/tests/ComposerProcessor/RaiseToInstalledComposerProcessor/Fixture/some-outdated-composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "illuminate/container": "^9.0" + } +} diff --git a/tests/ComposerProcessor/RaiseToInstalledComposerProcessor/RaiseToInstalledComposerProcessorTest.php b/tests/ComposerProcessor/RaiseToInstalledComposerProcessor/RaiseToInstalledComposerProcessorTest.php new file mode 100644 index 0000000..f987704 --- /dev/null +++ b/tests/ComposerProcessor/RaiseToInstalledComposerProcessor/RaiseToInstalledComposerProcessorTest.php @@ -0,0 +1,43 @@ +raiseToInstalledComposerProcessor = $this->make(RaiseToInstalledComposerProcessor::class); + } + + public function test(): void + { + $composerJsonContents = FileSystem::read(__DIR__ . '/Fixture/some-outdated-composer.json'); + + $raiseToInstalledResult = $this->raiseToInstalledComposerProcessor->process($composerJsonContents); + + $this->assertCount(1, $raiseToInstalledResult->getChangedPackageVersions()); + $this->assertContainsOnlyInstancesOf( + ChangedPackageVersion::class, + $raiseToInstalledResult->getChangedPackageVersions() + ); + + $changedPackageVersion = $raiseToInstalledResult->getChangedPackageVersions()[0]; + + $this->assertSame('illuminate/container', $changedPackageVersion->getPackageName()); + $this->assertSame('^9.0', $changedPackageVersion->getOldVersion()); + + // note: this might change in near future; improve to dynamic soon + $this->assertSame('^12.14', $changedPackageVersion->getNewVersion()); + } +}