diff --git a/src/Command/UpdateRecipesCommand.php b/src/Command/UpdateRecipesCommand.php index c75507eca..a43debeac 100644 --- a/src/Command/UpdateRecipesCommand.php +++ b/src/Command/UpdateRecipesCommand.php @@ -21,12 +21,12 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Flex\Configurator; -use Symfony\Flex\Downloader; use Symfony\Flex\Flex; use Symfony\Flex\GithubApi; use Symfony\Flex\InformationOperation; use Symfony\Flex\Lock; use Symfony\Flex\Recipe; +use Symfony\Flex\RecipeProviderInterface; use Symfony\Flex\Update\RecipePatcher; use Symfony\Flex\Update\RecipeUpdate; @@ -34,16 +34,16 @@ class UpdateRecipesCommand extends BaseCommand { /** @var Flex */ private $flex; - private $downloader; + private RecipeProviderInterface $recipeProvider; private $configurator; private $rootDir; private $githubApi; private $processExecutor; - public function __construct(/* cannot be type-hinted */ $flex, Downloader $downloader, $httpDownloader, Configurator $configurator, string $rootDir) + public function __construct(/* cannot be type-hinted */ $flex, RecipeProviderInterface $recipeProvider, $httpDownloader, Configurator $configurator, string $rootDir) { $this->flex = $flex; - $this->downloader = $downloader; + $this->recipeProvider = $recipeProvider; $this->configurator = $configurator; $this->rootDir = $rootDir; $this->githubApi = new GithubApi($httpDownloader); @@ -268,7 +268,7 @@ private function getRecipe(PackageInterface $package, string $recipeRef = null, if (null !== $recipeRef) { $operation->setSpecificRecipeVersion($recipeRef, $recipeVersion); } - $recipes = $this->downloader->getRecipes([$operation]); + $recipes = $this->recipeProvider->getRecipes([$operation]); if (0 === \count($recipes['manifests'] ?? [])) { return null; diff --git a/src/CompositeRecipeProvider.php b/src/CompositeRecipeProvider.php new file mode 100644 index 000000000..b80bb85e7 --- /dev/null +++ b/src/CompositeRecipeProvider.php @@ -0,0 +1,112 @@ +recipeProviders = array_reduce( + $recipeProviders, + function (array $providers, RecipeProviderInterface $provider) { + if (self::class == $provider::class) { + throw new \InvalidArgumentException('You cannot add an instance of this provider to itself.'); + } + $providers[$provider::class] = $provider; + + return $providers; + }, + []); + } + + /** + * This method adds an instance RecipeProviderInterface to this provider. + * You can only have one instance per class registered in this provider. + * + * @return $this + * + * @throws \InvalidArgumentException + */ + public function add(RecipeProviderInterface $recipeProvider): self + { + if (self::class == $recipeProvider::class) { + throw new \InvalidArgumentException('You cannot add an instance of this provider to itself.'); + } + if (isset($this->recipeProviders[$recipeProvider::class])) { + throw new \InvalidArgumentException('Given Provider has been added already.'); + } + $this->recipeProviders[] = $recipeProvider; + + return $this; + } + + /** + * {@inheritDoc} + */ + public function isEnabled(): bool + { + return array_reduce($this->recipeProviders, function (bool $isEnabled, RecipeProviderInterface $provider) { return $provider->isEnabled() && $isEnabled; }, true); + } + + /** + * {@inheritDoc} + */ + public function disable(): void + { + array_walk($this->recipeProviders, function (RecipeProviderInterface $provider) { $provider->disable(); }); + } + + /** + * {@inheritDoc} + */ + public function getVersions(): array + { + return array_reduce($this->recipeProviders, function (array $carry, RecipeProviderInterface $provider) { return array_merge($carry, $provider->getVersions()); }, []); + } + + /** + * {@inheritDoc} + */ + public function getAliases(): array + { + return array_reduce($this->recipeProviders, function (array $carry, RecipeProviderInterface $provider) { return array_merge($carry, $provider->getAliases()); }, []); + } + + /** + * {@inheritDoc} + */ + public function getRecipes(array $operations): array + { + return array_reduce($this->recipeProviders, function (array $carry, RecipeProviderInterface $provider) use ($operations) { return array_merge_recursive($carry, $provider->getRecipes($operations)); }, []); + } + + /** + * {@inheritDoc} + */ + public function removeRecipeFromIndex(string $packageName, string $version): void + { + array_walk($this->recipeProviders, function (RecipeProviderInterface $provider) use ($packageName, $version) { $provider->removeRecipeFromIndex($packageName, $version); }); + } + + public function getSessionId(): string + { + return implode(' ', array_reduce( + $this->recipeProviders, + function (array $carry, RecipeProviderInterface $provider) { + $carry[] = $provider::class.'=>'.$provider->getSessionId(); + + return $carry; + }, + [])); + } +} diff --git a/src/Downloader.php b/src/Downloader.php index 2adf00df8..3a51396b7 100644 --- a/src/Downloader.php +++ b/src/Downloader.php @@ -13,7 +13,6 @@ use Composer\Cache; use Composer\Composer; -use Composer\DependencyResolver\Operation\OperationInterface; use Composer\DependencyResolver\Operation\UninstallOperation; use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\IO\IOInterface; @@ -26,7 +25,7 @@ * @author Fabien Potencier * @author Nicolas Grekas */ -class Downloader +class Downloader implements RecipeProviderInterface { private const DEFAULT_ENDPOINTS = [ 'https://raw.githubusercontent.com/symfony/recipes/flex/main/index.json', @@ -95,29 +94,44 @@ public function __construct(Composer $composer, IoInterface $io, HttpDownloader $this->composer = $composer; } + /** + * {@inheritDoc} + */ public function getSessionId(): string { return $this->sess; } - public function isEnabled() + /** + * {@inheritDoc} + */ + public function isEnabled(): bool { return $this->enabled; } - public function disable() + /** + * {@inheritDoc} + */ + public function disable(): void { $this->enabled = false; } - public function getVersions() + /** + * {@inheritDoc} + */ + public function getVersions(): array { $this->initialize(); return self::$versions ?? self::$versions = current($this->get([$this->legacyEndpoint.'/versions.json'])); } - public function getAliases() + /** + * {@inheritDoc} + */ + public function getAliases(): array { $this->initialize(); @@ -125,9 +139,7 @@ public function getAliases() } /** - * Downloads recipes. - * - * @param OperationInterface[] $operations + * {@inheritDoc} */ public function getRecipes(array $operations): array { @@ -307,11 +319,9 @@ public function getRecipes(array $operations): array } /** - * Used to "hide" a recipe version so that the next most-recent will be returned. - * - * This is used when resolving "conflicts". + * {@inheritDoc} */ - public function removeRecipeFromIndex(string $packageName, string $version) + public function removeRecipeFromIndex(string $packageName, string $version): void { unset($this->index[$packageName][$version]); } diff --git a/src/Flex.php b/src/Flex.php index 29bcf56e1..e27904fed 100644 --- a/src/Flex.php +++ b/src/Flex.php @@ -65,7 +65,7 @@ class Flex implements PluginInterface, EventSubscriberInterface private $config; private $options; private $configurator; - private $downloader; + private RecipeProviderInterface $recipeProvider; /** * @var Installer @@ -112,10 +112,14 @@ class_exists(__NAMESPACE__.str_replace('/', '\\', substr($file, \strlen(__DIR__) $rfs = Factory::createHttpDownloader($this->io, $this->config); - $this->downloader = $downloader = new Downloader($composer, $io, $rfs); + $this->recipeProvider = new CompositeRecipeProvider( + [ + new Downloader($composer, $io, $rfs), + new LocalRecipeProvider($composer), + ]); if ($symfonyRequire) { - $this->filter = new PackageFilter($io, $symfonyRequire, $this->downloader); + $this->filter = new PackageFilter($io, $symfonyRequire, $this->recipeProvider); } $composerFile = Factory::getComposerFile(); @@ -134,7 +138,7 @@ class_exists(__NAMESPACE__.str_replace('/', '\\', substr($file, \strlen(__DIR__) } } if ($disable) { - $downloader->disable(); + $this->recipeProvider->disable(); } $backtrace = $this->configureInstaller(); @@ -153,7 +157,7 @@ class_exists(__NAMESPACE__.str_replace('/', '\\', substr($file, \strlen(__DIR__) $input = $trace['args'][0]; $app = $trace['object']; - $resolver = new PackageResolver($this->downloader); + $resolver = new PackageResolver($this->recipeProvider); try { $command = $input->getFirstArgument(); @@ -186,7 +190,7 @@ class_exists(__NAMESPACE__.str_replace('/', '\\', substr($file, \strlen(__DIR__) $app->add(new Command\RecipesCommand($this, $this->lock, $rfs)); $app->add(new Command\InstallRecipesCommand($this, $this->options->get('root-dir'), $this->options->get('runtime')['dotenv_path'] ?? '.env')); - $app->add(new Command\UpdateRecipesCommand($this, $this->downloader, $rfs, $this->configurator, $this->options->get('root-dir'))); + $app->add(new Command\UpdateRecipesCommand($this, $this->recipeProvider, $rfs, $this->configurator, $this->options->get('root-dir'))); $app->add(new Command\DumpEnvCommand($this->config, $this->options)); break; @@ -215,7 +219,7 @@ public function configureInstaller() } if (isset($trace['object']) && $trace['object'] instanceof GlobalCommand) { - $this->downloader->disable(); + $this->recipeProvider->disable(); } } @@ -224,7 +228,7 @@ public function configureInstaller() public function configureProject(Event $event) { - if (!$this->downloader->isEnabled()) { + if (!$this->recipeProvider->isEnabled()) { $this->io->writeError('Project configuration is disabled: "symfony/flex" not found in the root composer.json'); return; @@ -312,7 +316,7 @@ public function update(Event $event, $operations = []) $manipulator = new JsonManipulator($contents); $sortPackages = $this->composer->getConfig()->get('sort-packages'); $symfonyVersion = $json['extra']['symfony']['require'] ?? null; - $versions = $symfonyVersion ? $this->downloader->getVersions() : null; + $versions = $symfonyVersion ? $this->recipeProvider->getVersions() : null; foreach (['require', 'require-dev'] as $type) { if (!isset($json['flex-'.$type])) { continue; @@ -363,7 +367,7 @@ public function install(Event $event) $this->finish($rootDir); } - if ($this->downloader->isEnabled()) { + if ($this->recipeProvider->isEnabled()) { $this->io->writeError('Run composer recipes at any time to see the status of your Symfony recipes.'); $this->io->writeError(''); } @@ -371,7 +375,7 @@ public function install(Event $event) return; } - $this->io->writeError(sprintf('Symfony operations: %d recipe%s (%s)', \count($recipes), \count($recipes) > 1 ? 's' : '', $this->downloader->getSessionId())); + $this->io->writeError(sprintf('Symfony operations: %d recipe%s (%s)', \count($recipes), \count($recipes) > 1 ? 's' : '', $this->recipeProvider->getSessionId())); $installContribs = $this->composer->getPackage()->getExtra()['symfony']['allow-contrib'] ?? false; $manifest = null; $originalComposerJsonHash = $this->getComposerJsonHash(); @@ -527,13 +531,13 @@ public function executeAutoScripts(Event $event) */ public function fetchRecipes(array $operations, bool $reset): array { - if (!$this->downloader->isEnabled()) { + if (!$this->recipeProvider->isEnabled()) { $this->io->writeError('Symfony recipes are disabled: "symfony/flex" not found in the root composer.json'); return []; } $devPackages = null; - $data = $this->downloader->getRecipes($operations); + $data = $this->recipeProvider->getRecipes($operations); $manifests = $data['manifests'] ?? []; $locks = $data['locks'] ?? []; // symfony/flex recipes should always be applied first @@ -562,8 +566,8 @@ public function fetchRecipes(array $operations, bool $reset): array } while ($this->doesRecipeConflict($manifests[$name] ?? [], $operation)) { - $this->downloader->removeRecipeFromIndex($name, $manifests[$name]['version']); - $newData = $this->downloader->getRecipes([$operation]); + $this->recipeProvider->removeRecipeFromIndex($name, $manifests[$name]['version']); + $newData = $this->recipeProvider->getRecipes([$operation]); $newManifests = $newData['manifests'] ?? []; if (!isset($newManifests[$name])) { @@ -751,7 +755,7 @@ private function unpack(Event $event) } } - $unpacker = new Unpacker($this->composer, new PackageResolver($this->downloader), $this->dryRun); + $unpacker = new Unpacker($this->composer, new PackageResolver($this->recipeProvider), $this->dryRun); $result = $unpacker->unpack($unpackOp); if (!$result->getUnpacked()) { diff --git a/src/LocalRecipeProvider.php b/src/LocalRecipeProvider.php new file mode 100644 index 000000000..cb4b0d040 --- /dev/null +++ b/src/LocalRecipeProvider.php @@ -0,0 +1,117 @@ +composer = $composer; + } + + /** + * {@inheritDoc} + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * {@inheritDoc} + */ + public function disable(): void + { + $this->enabled = false; + } + + /** + * {@inheritDoc} + */ + public function getVersions(): array + { + return $this->versions; + } + + /** + * {@inheritDoc} + */ + public function getAliases(): array + { + return []; + } + + /** + * {@inheritDoc} + */ + public function getRecipes(array $operations): array + { + $data = []; + foreach ($operations as $operation) { + $package = $operation instanceof UpdateOperation ? $operation->getTargetPackage() : $operation->getPackage(); + + $installPath = $this->composer->getInstallationManager()->getInstallPath($package); + $jsonFile = new JsonFile($installPath.\DIRECTORY_SEPARATOR.'manifest.json'); + if ($jsonFile->exists()) { + $manifest = $jsonFile->read(); + if (isset($manifest['manifest']['copy-from-recipe'])) { + $copyFolders = array_keys($manifest['manifest']['copy-from-recipe']); + foreach ($copyFolders as $folder) { + $folderPattern = $installPath.\DIRECTORY_SEPARATOR.$folder; + $dir_iterator = new RecursiveDirectoryIterator($folderPattern); + $iterator = new RecursiveIteratorIterator($dir_iterator, RecursiveIteratorIterator::SELF_FIRST); + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if (is_file($file->getPathname())) { + $manifest['files'][str_replace($installPath.\DIRECTORY_SEPARATOR, '', $file->getPathname())] = [ + 'contents' => file_get_contents($file->getPathname()), + 'executable' => is_executable($file->getPathname()), ]; + } + } + } + } + $manifest['origin'] = $installPath; + $manifest['is_contrib'] = false; + $manifest['version'] = $package->getVersion(); + $manifest['package'] = $package->getName(); + $data['manifests'][$package->getName()] = $manifest; + $data['locks'][$package->getName()]['recipe']['ref'] = $manifest['ref']; + $data['locks'][$package->getName()]['version'] = $package->getVersion(); + + if (!isset($this->versions[$package->getName()])) { + $this->versions[$package->getName()] = []; + } + $this->versions[$package->getName()][] = $package->getVersion(); + } + } + + return $data; + } + + /** + * {@inheritDoc} + */ + public function removeRecipeFromIndex(string $packageName, string $version): void + { + } + + /** + * {@inheritDoc} + */ + public function getSessionId(): string + { + return 'none'; + } +} diff --git a/src/PackageFilter.php b/src/PackageFilter.php index d1d9709b5..19a8ff3e0 100644 --- a/src/PackageFilter.php +++ b/src/PackageFilter.php @@ -28,15 +28,15 @@ class PackageFilter private $versionParser; private $symfonyRequire; private $symfonyConstraints; - private $downloader; + private $recipeProvider; private $io; - public function __construct(IOInterface $io, string $symfonyRequire, Downloader $downloader) + public function __construct(IOInterface $io, string $symfonyRequire, RecipeProviderInterface $recipeProvider) { $this->versionParser = new VersionParser(); $this->symfonyRequire = $symfonyRequire; $this->symfonyConstraints = $this->versionParser->parseConstraints($symfonyRequire); - $this->downloader = $downloader; + $this->recipeProvider = $recipeProvider; $this->io = $io; } @@ -118,8 +118,8 @@ private function getVersions(): array return $this->versions; } - $versions = $this->downloader->getVersions(); - $this->downloader = null; + $versions = $this->recipeProvider->getVersions(); + $this->recipeProvider = null; $okVersions = []; if (!isset($versions['splits'])) { diff --git a/src/PackageResolver.php b/src/PackageResolver.php index 878b5a8f8..3725a7e71 100644 --- a/src/PackageResolver.php +++ b/src/PackageResolver.php @@ -21,11 +21,11 @@ class PackageResolver { private static $SYMFONY_VERSIONS = ['lts', 'previous', 'stable', 'next', 'dev']; - private $downloader; + private RecipeProviderInterface $recipeProvider; - public function __construct(Downloader $downloader) + public function __construct(RecipeProviderInterface $recipeProvider) { - $this->downloader = $downloader; + $this->recipeProvider = $recipeProvider; } public function resolve(array $arguments = [], bool $isRequire = false): array @@ -58,7 +58,7 @@ public function parseVersion(string $package, string $version, bool $isRequire): return $version ? ':'.$version : ''; } - $versions = $this->downloader->getVersions(); + $versions = $this->recipeProvider->getVersions(); if (!isset($versions['splits'][$package])) { return $version ? ':'.$version : ''; @@ -90,7 +90,7 @@ private function resolvePackageName(string $argument, int $position): string return $argument; } - $aliases = $this->downloader->getAliases(); + $aliases = $this->recipeProvider->getAliases(); if (isset($aliases[$argument])) { $argument = $aliases[$argument]; @@ -116,7 +116,7 @@ private function resolvePackageName(string $argument, int $position): string private function throwAlternatives(string $argument, int $position) { $alternatives = []; - foreach ($this->downloader->getAliases() as $alias => $package) { + foreach ($this->recipeProvider->getAliases() as $alias => $package) { $lev = levenshtein($argument, $alias); if ($lev <= \strlen($argument) / 3 || ('' !== $argument && false !== strpos($alias, $argument))) { $alternatives[$package][] = $alias; diff --git a/src/RecipeProviderInterface.php b/src/RecipeProviderInterface.php new file mode 100644 index 000000000..9650cc941 --- /dev/null +++ b/src/RecipeProviderInterface.php @@ -0,0 +1,38 @@ +