diff --git a/.travis.yml b/.travis.yml index 8ba4b4e7e..d78190a57 100644 --- a/.travis.yml +++ b/.travis.yml @@ -29,3 +29,4 @@ install: script: - ./vendor/bin/simple-phpunit + - find src/ -name '*.php' | xargs -n1 php -l diff --git a/src/Cache.php b/src/Cache.php index 7123d2cda..825011ee2 100644 --- a/src/Cache.php +++ b/src/Cache.php @@ -12,70 +12,27 @@ namespace Symfony\Flex; use Composer\Cache as BaseCache; +use Composer\IO\IOInterface; +use Composer\Semver\Constraint\Constraint; +use Composer\Semver\VersionParser; /** * @author Nicolas Grekas */ class Cache extends BaseCache { - private static $lowestTags = [ - 'symfony/symfony' => [ - 'version' => 'v3.4.0', - 'replaces' => [ - 'symfony/asset', - 'symfony/browser-kit', - 'symfony/cache', - 'symfony/config', - 'symfony/console', - 'symfony/css-selector', - 'symfony/dependency-injection', - 'symfony/debug', - 'symfony/debug-bundle', - 'symfony/doctrine-bridge', - 'symfony/dom-crawler', - 'symfony/dotenv', - 'symfony/event-dispatcher', - 'symfony/expression-language', - 'symfony/filesystem', - 'symfony/finder', - 'symfony/form', - 'symfony/framework-bundle', - 'symfony/http-foundation', - 'symfony/http-kernel', - 'symfony/inflector', - 'symfony/intl', - 'symfony/ldap', - 'symfony/lock', - 'symfony/messenger', - 'symfony/monolog-bridge', - 'symfony/options-resolver', - 'symfony/process', - 'symfony/property-access', - 'symfony/property-info', - 'symfony/proxy-manager-bridge', - 'symfony/routing', - 'symfony/security', - 'symfony/security-core', - 'symfony/security-csrf', - 'symfony/security-guard', - 'symfony/security-http', - 'symfony/security-bundle', - 'symfony/serializer', - 'symfony/stopwatch', - 'symfony/templating', - 'symfony/translation', - 'symfony/twig-bridge', - 'symfony/twig-bundle', - 'symfony/validator', - 'symfony/var-dumper', - 'symfony/web-link', - 'symfony/web-profiler-bundle', - 'symfony/web-server-bundle', - 'symfony/workflow', - 'symfony/yaml', - ], - ], - ]; + private $versionParser; + private $symfonyRequire; + private $symfonyContraints; + private $io; + + public function setSymfonyRequire(string $symfonyRequire, IOInterface $io) + { + $this->versionParser = new VersionParser(); + $this->symfonyRequire = $symfonyRequire; + $this->symfonyContraints = $this->versionParser->parseConstraints($symfonyRequire); + $this->io = $io; + } public function read($file) { @@ -90,23 +47,28 @@ public function read($file) public function removeLegacyTags(array $data): array { - foreach (self::$lowestTags as $lowestPackage => $settings) { - $lowestVersion = $settings['version']; - $replacedPackages = $settings['replaces']; - if (!isset($data['packages'][$lowestPackage][$lowestVersion])) { - continue; - } - foreach ($data['packages'] as $package => $versions) { - if ($package !== $lowestPackage && !in_array($package, $replacedPackages, true)) { + if (!$this->symfonyContraints || !isset($data['packages']['symfony/symfony'])) { + return $data; + } + $symfonyVersions = $data['packages']['symfony/symfony']; + + foreach ($data['packages'] as $name => $versions) { + foreach ($versions as $version => $package) { + if ('symfony/symfony' !== $name && 'self.version' !== ($symfonyVersions[preg_replace('/^(\d++\.\d++)\..*/', '$1.x-dev', $version)]['replace'][$name] ?? null)) { continue; } - foreach ($versions as $version => $composerJson) { - if (version_compare($version, $lowestVersion, '<')) { - unset($data['packages'][$package][$version]); + $normalizedVersion = $package['extra']['branch-alias'][$version] ?? null; + $normalizedVersion = $normalizedVersion ? $this->versionParser->normalize($normalizedVersion) : $package['version_normalized']; + $provider = new Constraint('==', $normalizedVersion); + + if (!$this->symfonyContraints->matches($provider)) { + if ($this->io) { + $this->io->writeError(sprintf('Restricting packages listed in "symfony/symfony" to "%s"', $this->symfonyRequire)); + $this->io = null; } + unset($data['packages'][$name][$version]); } } - break; } return $data; diff --git a/src/Downloader.php b/src/Downloader.php index 753c7106e..cdb85275c 100644 --- a/src/Downloader.php +++ b/src/Downloader.php @@ -42,21 +42,21 @@ public function __construct(Composer $composer, IoInterface $io, ParallelDownloa if (getenv('SYMFONY_CAFILE')) { $this->caFile = getenv('SYMFONY_CAFILE'); } - if (getenv('SYMFONY_ENDPOINT')) { - $endpoint = getenv('SYMFONY_ENDPOINT'); - } else { - $endpoint = $composer->getPackage()->getExtra()['symfony']['endpoint'] ?? self::$DEFAULT_ENDPOINT; + + foreach ($composer->getPackage()->getRequires() as $link) { + // recipes apply only when symfony/flex is found in "require" in the root package + if ('symfony/flex' !== $link->getTarget()) { + continue; + } + $this->endpoint = rtrim(getenv('SYMFONY_ENDPOINT') ?: ($composer->getPackage()->getExtra()['symfony']['endpoint'] ?? self::$DEFAULT_ENDPOINT), '/'); + break; } - $this->endpoint = rtrim($endpoint, '/'); + $this->io = $io; $config = $composer->getConfig(); $this->rfs = $rfs; $this->cache = new ComposerCache($io, $config->get('cache-repo-dir').'/'.preg_replace('{[^a-z0-9.]}i', '-', $this->endpoint)); $this->sess = bin2hex(random_bytes(16)); - - if (self::$DEFAULT_ENDPOINT !== $endpoint) { - $this->io->writeError('Warning: Using '.$endpoint.' as the Symfony endpoint'); - } } public function getSessionId(): string @@ -69,6 +69,11 @@ public function setFlexId(string $id = null) $this->flexId = $id; } + public function getEndpoint() + { + return $this->endpoint; + } + /** * Downloads recipes. * @@ -120,6 +125,10 @@ public function getRecipes(array $operations): array $paths[] = ['/p/'.$chunk]; } + if (null !== $this->endpoint && self::$DEFAULT_ENDPOINT !== $this->endpoint) { + $this->io->writeError('Using "'.$this->endpoint.'" as the Symfony endpoint'); + } + $bodies = []; $this->rfs->download($paths, function ($path) use (&$bodies) { if ($body = $this->get($path, [], false)->getBody()) { @@ -151,6 +160,9 @@ public function getRecipes(array $operations): array */ public function get(string $path, array $headers = [], $cache = true): Response { + if (null === $this->endpoint) { + return new Response([]); + } $headers[] = 'Package-Session: '.$this->sess; $url = $this->endpoint.'/'.ltrim($path, '/'); $cacheKey = $cache ? ltrim($path, '/') : ''; diff --git a/src/Flex.php b/src/Flex.php index 75b779051..7828c038a 100644 --- a/src/Flex.php +++ b/src/Flex.php @@ -30,6 +30,7 @@ use Composer\IO\NullIO; use Composer\Json\JsonFile; use Composer\Json\JsonManipulator; +use Composer\Package\BasePackage; use Composer\Package\Comparer\Comparer; use Composer\Package\Locker; use Composer\Package\PackageInterface; @@ -105,14 +106,19 @@ public function activate(Composer $composer, IOInterface $io) $rfs = Factory::createRemoteFilesystem($this->io, $this->config); $this->rfs = new ParallelDownloader($this->io, $this->config, $rfs->getOptions(), $rfs->isTlsDisabled()); + $symfonyRequire = getenv('SYMFONY_REQUIRE') ?: ($composer->getPackage()->getExtra()['symfony']['require'] ?? null); + $manager = RepositoryFactory::manager($this->io, $this->config, $composer->getEventDispatcher(), $this->rfs); - $setRepositories = \Closure::bind(function (RepositoryManager $manager) { + $setRepositories = \Closure::bind(function (RepositoryManager $manager) use ($symfonyRequire) { $manager->repositoryClasses = $this->repositoryClasses; $manager->setRepositoryClass('composer', TruncatedComposerRepository::class); $manager->repositories = $this->repositories; $i = 0; foreach (RepositoryFactory::defaultRepos(null, $this->config, $manager) as $repo) { $manager->repositories[$i++] = $repo; + if ($repo instanceof TruncatedComposerRepository && $symfonyRequire) { + $repo->setSymfonyRequire($symfonyRequire, $this->io); + } } $manager->setLocalRepository($this->getLocalRepository()); }, $composer->getRepositoryManager(), RepositoryManager::class); @@ -156,6 +162,8 @@ public function activate(Composer $composer, IOInterface $io) continue; } + // In Composer 1.0.*, $input knows about option and argument definitions + // Since Composer >=1.1, $input contains only raw values $input = $trace['args'][0]; $app = $trace['object']; @@ -195,12 +203,19 @@ public function activate(Composer $composer, IOInterface $io) } } - if ($input->hasOption('no-progress')) { - $this->progress = !$input->getOption('no-progress'); + if ($input->hasParameterOption('--no-progress', true)) { + $this->progress = false; + } + + if ($input->hasParameterOption('--dry-run', true)) { + $this->dryRun = true; } - if ($input->hasOption('dry-run')) { - $this->dryRun = $input->getOption('dry-run'); + if ($input->hasParameterOption('--prefer-lowest', true)) { + // When prefer-lowest is set and no stable version has been released, + // we consider "dev" more stable than "alpha", "beta" or "RC". This + // allows testing lowest versions with potential fixes applied. + BasePackage::$stabilities['dev'] = 1 + BasePackage::STABILITY_STABLE; } $composerFile = Factory::getComposerFile(); @@ -530,6 +545,10 @@ public function generateFlexId() return; } + if (null === $this->downloader->getEndpoint()) { + throw new \LogicException('Cannot generate project id when "symfony/flex" is not in the "require" section of the root composer.json.'); + } + $json = new JsonFile(Factory::getComposerFile()); $manipulator = new JsonManipulator(file_get_contents($json->getPath())); $manipulator->addSubNode('extra', 'symfony.id', $this->downloader->get('/ulid')->getBody()['ulid']); @@ -540,6 +559,11 @@ public function generateFlexId() private function fetchRecipes(): array { + if (null === $this->downloader->getEndpoint()) { + $this->io->writeError('Symfony recipes are disabled: "symfony/flex" is not in the "require" section of the root composer.json'); + + return [[], []]; + } $devPackages = null; $data = $this->downloader->getRecipes($this->operations); $manifests = $data['manifests'] ?? []; diff --git a/src/Lock.php b/src/Lock.php index 0f320f9fb..70ae8efea 100644 --- a/src/Lock.php +++ b/src/Lock.php @@ -46,7 +46,11 @@ public function remove($name) public function write() { - ksort($this->lock); - $this->json->write($this->lock); + if ($this->lock) { + ksort($this->lock); + $this->json->write($this->lock); + } elseif ($this->json->exists()) { + @unlink($this->json->getPath()); + } } } diff --git a/src/ParallelDownloader.php b/src/ParallelDownloader.php index 9d69eefde..8fe02dc56 100644 --- a/src/ParallelDownloader.php +++ b/src/ParallelDownloader.php @@ -83,8 +83,12 @@ public function download(array &$nextArgs, callable $nextCallback, bool $quiet = } try { $this->getNext(); - if (!$this->quiet) { + if ($this->quiet) { + // no-op + } elseif ($this->progress) { $this->io->overwriteError(' (100%)'); + } else { + $this->io->writeError(' (100%)'); } } finally { if (!$this->quiet) { diff --git a/src/Recipe.php b/src/Recipe.php index 1c6b30107..2238b87a4 100644 --- a/src/Recipe.php +++ b/src/Recipe.php @@ -73,7 +73,7 @@ public function getURL(): string // symfony/translation:3.3@github.com/symfony/recipes:master if (!preg_match('/^([^\:]+?)\:([^\@]+)@([^\:]+)\:(.+)$/', $this->data['origin'], $matches)) { - // that exclude auto-generated recipes, which is what we want + // that excludes auto-generated recipes, which is what we want return ''; } diff --git a/src/TruncatedComposerRepository.php b/src/TruncatedComposerRepository.php index b49231e4d..69fb7d9b8 100644 --- a/src/TruncatedComposerRepository.php +++ b/src/TruncatedComposerRepository.php @@ -29,6 +29,11 @@ public function __construct(array $repoConfig, IOInterface $io, Config $config, $this->cache = new Cache($io, $config->get('cache-repo-dir').'/'.preg_replace('{[^a-z0-9.]}i', '-', $this->url), 'a-z0-9.$'); } + public function setSymfonyRequire(string $symfonyRequire, IOInterface $io) + { + $this->cache->setSymfonyRequire($symfonyRequire, $io); + } + protected function fetchFile($filename, $cacheKey = null, $sha256 = null, $storeLastModifiedTime = false) { $data = parent::fetchFile($filename, $cacheKey, $sha256, $storeLastModifiedTime); diff --git a/tests/FlexTest.php b/tests/FlexTest.php index 7c9fbe744..bc3437fd2 100644 --- a/tests/FlexTest.php +++ b/tests/FlexTest.php @@ -16,6 +16,7 @@ use Composer\Factory; use Composer\Installer\PackageEvent; use Composer\IO\BufferIO; +use Composer\Package\Link; use Composer\Package\Locker; use Composer\Package\Package; use Composer\Package\RootPackageInterface; @@ -106,6 +107,7 @@ public function testPostInstall() $downloader = $this->getMockBuilder(Downloader::class)->disableOriginalConstructor()->getMock(); $downloader->expects($this->once())->method('getRecipes')->willReturn($data); + $downloader->expects($this->once())->method('getEndpoint')->willReturn('dummy'); $io = new BufferIO('', OutputInterface::VERBOSITY_VERBOSE); $locker = $this->getMockBuilder(Locker::class)->disableOriginalConstructor()->getMock(); @@ -167,6 +169,7 @@ public function testActivateLoadsClasses() $composer->setConfig(Factory::createConfig($io)); $package = $this->getMockBuilder(RootPackageInterface::class)->disableOriginalConstructor()->getMock(); $package->method('getExtra')->will($this->returnValue(['symfony' => ['allow-contrib' => true]])); + $package->method('getRequires')->will($this->returnValue([new Link('dummy', 'symfony/flex')])); $composer->setPackage($package); $localRepo = $this->getMockBuilder(WritableRepositoryInterface::class)->disableOriginalConstructor()->getMock(); $manager = $this->getMockBuilder(RepositoryManager::class)->disableOriginalConstructor()->getMock();