diff options
Diffstat (limited to 'composer/Plugin')
-rw-r--r-- | composer/Plugin/RecipeUnpack/CommandProvider.php | 21 | ||||
-rw-r--r-- | composer/Plugin/RecipeUnpack/Plugin.php | 170 | ||||
-rw-r--r-- | composer/Plugin/RecipeUnpack/README.md | 256 | ||||
-rw-r--r-- | composer/Plugin/RecipeUnpack/RootComposer.php | 161 | ||||
-rw-r--r-- | composer/Plugin/RecipeUnpack/SemVer.php | 64 | ||||
-rw-r--r-- | composer/Plugin/RecipeUnpack/UnpackCollection.php | 104 | ||||
-rw-r--r-- | composer/Plugin/RecipeUnpack/UnpackCommand.php | 105 | ||||
-rw-r--r-- | composer/Plugin/RecipeUnpack/UnpackManager.php | 99 | ||||
-rw-r--r-- | composer/Plugin/RecipeUnpack/UnpackOptions.php | 79 | ||||
-rw-r--r-- | composer/Plugin/RecipeUnpack/Unpacker.php | 217 | ||||
-rw-r--r-- | composer/Plugin/RecipeUnpack/composer.json | 26 |
11 files changed, 1302 insertions, 0 deletions
diff --git a/composer/Plugin/RecipeUnpack/CommandProvider.php b/composer/Plugin/RecipeUnpack/CommandProvider.php new file mode 100644 index 000000000000..ae7632024baa --- /dev/null +++ b/composer/Plugin/RecipeUnpack/CommandProvider.php @@ -0,0 +1,21 @@ +<?php + +namespace Drupal\Composer\Plugin\RecipeUnpack; + +use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability; + +/** + * List of all commands provided by this package. + * + * @internal + */ +final class CommandProvider implements CommandProviderCapability { + + /** + * {@inheritdoc} + */ + public function getCommands(): array { + return [new UnpackCommand()]; + } + +} diff --git a/composer/Plugin/RecipeUnpack/Plugin.php b/composer/Plugin/RecipeUnpack/Plugin.php new file mode 100644 index 000000000000..fe294ecb8b78 --- /dev/null +++ b/composer/Plugin/RecipeUnpack/Plugin.php @@ -0,0 +1,170 @@ +<?php + +namespace Drupal\Composer\Plugin\RecipeUnpack; + +use Composer\Command\RequireCommand; +use Composer\Composer; +use Composer\EventDispatcher\EventSubscriberInterface; +use Composer\Installer; +use Composer\IO\IOInterface; +use Composer\Package\PackageInterface; +use Composer\Plugin\Capability\CommandProvider; +use Composer\Plugin\Capable; +use Composer\Plugin\PluginInterface; +use Composer\Script\Event; +use Composer\Script\ScriptEvents; +use Drupal\Composer\Plugin\RecipeUnpack\CommandProvider as UnpackCommandProvider; + +/** + * Composer plugin for handling dependency unpacking. + * + * @internal + */ +final class Plugin implements PluginInterface, EventSubscriberInterface, Capable { + + /** + * The composer package type of Drupal recipes. + */ + public const string RECIPE_PACKAGE_TYPE = 'drupal-recipe'; + + /** + * The handler for dependency unpacking. + */ + private UnpackManager $manager; + + /** + * {@inheritdoc} + */ + public function getCapabilities(): array { + return [CommandProvider::class => UnpackCommandProvider::class]; + } + + /** + * {@inheritdoc} + */ + public function activate(Composer $composer, IOInterface $io): void { + $this->manager = new UnpackManager($composer, $io); + } + + /** + * {@inheritdoc} + */ + public function deactivate(Composer $composer, IOInterface $io): void { + } + + /** + * {@inheritdoc} + */ + public function uninstall(Composer $composer, IOInterface $io): void { + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + ScriptEvents::POST_UPDATE_CMD => 'unpackOnRequire', + ScriptEvents::POST_CREATE_PROJECT_CMD => 'unpackOnCreateProject', + ]; + } + + /** + * Post update command event callback. + */ + public function unpackOnRequire(Event $event): void { + if (!$this->manager->unpackOptions->options['on-require']) { + return; + } + + // @todo https://www.drupal.org/project/drupal/issues/3523269 Use Composer + // API once it exists. + $backtrace = debug_backtrace(); + $composer = $event->getComposer(); + foreach ($backtrace as $trace) { + if (isset($trace['object']) && $trace['object'] instanceof Installer) { + $installer = $trace['object']; + + // Get the list of packages being required. This code is largely copied + // from https://github.com/symfony/flex/blob/2.x/src/Flex.php#L218. + $updateAllowList = \Closure::bind(function () { + return $this->updateAllowList ?? []; + }, $installer, $installer)(); + + // Determine if the --no-install flag has been passed to require. + $isInstalling = \Closure::bind(function () { + return $this->install; + }, $installer, $installer)(); + } + + // If the command is a require command, populate the list of recipes to + // unpack. + if (isset($trace['object']) && $trace['object'] instanceof RequireCommand && isset($installer, $updateAllowList, $isInstalling)) { + // Determines if a message has been sent about require-dev and recipes. + $devRecipeWarningEmitted = FALSE; + $unpackCollection = new UnpackCollection(); + + foreach ($updateAllowList as $package_name) { + $packages = $composer->getRepositoryManager()->getLocalRepository()->findPackages($package_name); + $package = reset($packages); + + if (!$package instanceof PackageInterface) { + if (!$isInstalling) { + $event->getIO()->write('Recipes are not unpacked when the --no-install option is used.', verbosity: IOInterface::VERBOSE); + return; + } + $event->getIO()->error(sprintf('%s does not resolve to a package.', $package_name)); + return; + } + + // Only recipes are supported. + if ($package->getType() === self::RECIPE_PACKAGE_TYPE) { + if ($this->manager->unpackOptions->isIgnored($package)) { + $event->getIO()->write(sprintf('<info>%s</info> not unpacked because it is ignored.', $package_name), verbosity: IOInterface::VERBOSE); + } + elseif (UnpackManager::isDevRequirement($package)) { + if (!$devRecipeWarningEmitted) { + $event->getIO()->write('<info>Recipes required as a development dependency are not automatically unpacked.</info>'); + $devRecipeWarningEmitted = TRUE; + } + } + else { + $unpackCollection->add($package); + } + } + } + + // Unpack any recipes that have been added to the collection. + $this->manager->unpack($unpackCollection); + // The trace has been processed far enough and the $updateAllowList has + // been used. + break; + } + } + } + + /** + * Post create-project command event callback. + */ + public function unpackOnCreateProject(Event $event): void { + $composer = $event->getComposer(); + $unpackCollection = new UnpackCollection(); + foreach ($composer->getRepositoryManager()->getLocalRepository()->getPackages() as $package) { + // Only recipes are supported. + if ($package->getType() === self::RECIPE_PACKAGE_TYPE) { + if ($this->manager->unpackOptions->isIgnored($package)) { + $event->getIO()->write(sprintf('<info>%s</info> not unpacked because it is ignored.', $package->getName()), verbosity: IOInterface::VERBOSE); + } + elseif (UnpackManager::isDevRequirement($package)) { + continue; + } + else { + $unpackCollection->add($package); + } + } + } + + // Unpack any recipes that have been registered. + $this->manager->unpack($unpackCollection); + } + +} diff --git a/composer/Plugin/RecipeUnpack/README.md b/composer/Plugin/RecipeUnpack/README.md new file mode 100644 index 000000000000..fcb4e42527ec --- /dev/null +++ b/composer/Plugin/RecipeUnpack/README.md @@ -0,0 +1,256 @@ +# Drupal Recipe Unpack Plugin + +Thanks for using this Drupal component. + +You can participate in its development on Drupal.org, through our issue system: +https://www.drupal.org/project/issues/drupal + +You can get the full Drupal repo here: +https://www.drupal.org/project/drupal/git-instructions + +You can browse the full Drupal repo here: +https://git.drupalcode.org/project/drupal + +## Overview + +The Recipe Unpacking system is a Composer plugin that manages "drupal-recipe" +packages. Recipes are special Composer packages designed to bootstrap Drupal +projects with necessary dependencies. When a recipe is installed, this plugin +"unpacks" it by moving the recipe's dependencies directly into your project's +root `composer.json`, and removes the recipe as a project dependency. + +## Key Concepts + +### What is a Recipe? + +A recipe is a Composer package with type `drupal-recipe` that contains a curated +set of dependencies, configuration and content but no code of its own. Recipes +are meant to be "unpacked" and "applied" rather than remain as runtime +dependencies. + +### What is Unpacking? + +Unpacking is the process where: + +1. A recipe's dependencies are added to your project's root `composer.json` +2. The recipe itself is removed from your dependencies +3. The `composer.lock` and vendor installation files are updated accordingly +4. The recipe will remain in the project's recipes folder so it can be applied + +## Commands + +### `drupal:recipe-unpack` + +Unpack a recipe package that's already required in your project. + +```bash +composer drupal:recipe-unpack drupal/example_recipe +``` + +Unpack all recipes that are required in your project. + +```bash +composer drupal:recipe-unpack +``` + +#### Options + +This command doesn't take additional options. + +## Automatic Unpacking + +### After `composer require` + +By default, recipes are automatically unpacked after running `composer require` +for a recipe package: + +```bash +composer require drupal/example_recipe +``` + +This will: +1. Download the recipe and its dependencies +2. Add the recipe's dependencies to your project's root `composer.json` +3. Remove the recipe itself from your dependencies +4. Update your `composer.lock` file + +### After `composer create-project` + +Recipes are always automatically unpacked when creating a new project from a +template that requires this plugin: + +```bash +composer create-project drupal/recommended-project my-project +``` + +Any recipes included in the project template will be unpacked during +installation, as long as the plugin is enabled. + +## Configuration + +Configuration options are set in the `extra` section of your `composer.json` +file: + +```json +{ + "extra": { + "drupal-recipe-unpack": { + "ignore": ["drupal/recipe_to_ignore"], + "on-require": true + } + } +} +``` + +### Available Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `ignore` | array | `[]` | List of recipe packages to exclude from unpacking | +| `on-require` | boolean | `true` | Automatically unpack recipes when required by `composer require` | + +## How Recipe Unpacking Works + +1. The system identifies packages of type `drupal-recipe` during installation +2. For each recipe not in the ignore list, it: + - Extracts its dependencies + - Adds them to the root `composer.json` + - Recursively processes any dependencies that are also recipes + - Removes the recipe and any dependencies that are also recipes from the root + `composer.json` +3. Updates all necessary Composer files: + - `composer.json` + - `composer.lock` + - `vendor/composer/installed.json` + - `vendor/composer/installed.php` + +## Cases Where Recipes Will Not Be Unpacked + +Recipes will **not** be unpacked in the following scenarios: + +1. **Explicit Ignore List**: If the recipe is listed in the `ignore` array in + your `extra.drupal-recipe-unpack` configuration + ```json + { + "extra": { + "drupal-recipe-unpack": { + "ignore": ["drupal/recipe_name"] + } + } + } + ``` + +2. **Disabled Automatic Unpacking**: If `on-require` is set to `false` in your + `extra.drupal-recipe-unpack` configuration + ```json + { + "extra": { + "drupal-recipe-unpack": { + "on-require": false + } + } + } + ``` + +3. **Development Dependencies**: Recipes in the `require-dev` section are not + automatically unpacked + ```json + { + "require-dev": { + "drupal/dev_recipe": "^1.0" + } + } + ``` + You will need to manually unpack these using the `drupal:recipe-unpack` + command if desired. + +4. **With `--no-install` Option**: When using `composer require` with the + `--no-install` flag + ```bash + composer require drupal/example_recipe --no-install + ``` + In this case, you'll need to run `composer install` afterward and then + manually unpack using the `drupal:recipe-unpack` command. + +## Example Usage Scenarios + +### Basic Recipe Installation + +```bash +# This will automatically install and unpack the recipe +composer require drupal/example_recipe +``` + +The result: +- Dependencies from `drupal/example_recipe` are added to your root + `composer.json` +- `drupal/example_recipe` itself is removed from your dependencies +- You'll see a message: "drupal/example_recipe unpacked successfully." +- The recipe files will be present in the drupal-recipe installer path + +### Manual Recipe Unpacking + +```bash +# First require the recipe without unpacking +composer require drupal/example_recipe --no-install +composer install + +# Then manually unpack it +composer drupal:recipe-unpack drupal/example_recipe +``` + +### Working with Dev Recipes + +```bash +# This won't automatically unpack (dev dependencies aren't auto-unpacked) +composer require --dev drupal/dev_recipe + +# You'll need to manually unpack if desired (with confirmation prompt) +composer drupal:recipe-unpack drupal/dev_recipe +``` + +### Creating a New Project with Recipes + +```bash +composer create-project drupal/recipe-based-project my-project +``` + +Any recipes included in the project template will be automatically unpacked +during installation. + +## Best Practices + +1. **Review Recipe Contents**: Before requiring a recipe, review its + dependencies to understand what will be added to your project. + +2. **Consider Versioning**: When a recipe is unpacked, its version constraints + for dependencies are merged with your existing constraints, which may result + in complex version requirements. + +3. **Dev Dependencies**: Be cautious when unpacking development recipes, as + their dependencies will be moved to the main `require` section, not + `require-dev`. + +4. **Custom Recipes**: When creating custom recipes, ensure they have the + correct package type `drupal-recipe` and include appropriate dependencies. + +## Troubleshooting + +### Recipe Not Unpacking + +- Check if the package type is `drupal-recipe` +- Verify it's not in your ignore list +- Confirm it's not in `require-dev` (which requires manual unpacking) +- Ensure you haven't used the `--no-install` flag without following up with + installation and manual unpacking + +### Unpacking Errors + +If you encounter issues during unpacking: + +1. Check Composer's error output for specific issues and run commands with the + `--verbose` flag +2. Verify that version constraints between your existing dependencies and the + recipe's dependencies are compatible +3. For manual troubleshooting, consider temporarily setting `on-require` to + `false` and unpacking recipes one by one diff --git a/composer/Plugin/RecipeUnpack/RootComposer.php b/composer/Plugin/RecipeUnpack/RootComposer.php new file mode 100644 index 000000000000..da07224a275c --- /dev/null +++ b/composer/Plugin/RecipeUnpack/RootComposer.php @@ -0,0 +1,161 @@ +<?php + +namespace Drupal\Composer\Plugin\RecipeUnpack; + +use Composer\Composer; +use Composer\Factory; +use Composer\IO\IOInterface; +use Composer\Json\JsonFile; +use Composer\Json\JsonManipulator; +use Composer\Package\Locker; +use Composer\Package\RootPackageInterface; + +/** + * Provides access to and manipulation of the root composer files. + * + * @internal + */ +final class RootComposer { + + /** + * The JSON manipulator for the contents of the root composer.json. + */ + private JsonManipulator $composerManipulator; + + /** + * The locked root composer.json content. + * + * @var array<string, mixed>|null + */ + private ?array $composerLockedContent = NULL; + + public function __construct( + private readonly Composer $composer, + private readonly IOInterface $io, + ) {} + + /** + * Retrieves the JSON manipulator for the contents of the root composer.json. + * + * @return \Composer\Json\JsonManipulator + * The JSON manipulator. + */ + public function getComposerManipulator(): JsonManipulator { + $this->composerManipulator ??= new JsonManipulator(file_get_contents(Factory::getComposerFile())); + return $this->composerManipulator; + } + + /** + * Gets the locked root composer.json content. + * + * @return array<string, mixed> + * The locked root composer.json content. + */ + public function getComposerLockedContent(): array { + $this->composerLockedContent ??= $this->composer->getLocker()->getLockData(); + return $this->composerLockedContent; + } + + /** + * Removes an element from the composer lock. + * + * @param string $key + * The key of the element to remove. + * @param string $index + * The index of the element to remove. + */ + public function removeFromComposerLock(string $key, string $index): void { + unset($this->composerLockedContent[$key][$index]); + } + + /** + * Adds an element to the composer lock. + * + * @param string $key + * The key of the element to add. + * @param array $data + * The data to add. + */ + public function addToComposerLock(string $key, array $data): void { + $this->composerLockedContent[$key][] = $data; + } + + /** + * Writes the root composer files. + * + * The files written are: + * - composer.json + * - composer.lock + * - vendor/composer/installed.json + * - vendor/composer/installed.php + * + * @throws \RuntimeException + * If the root composer could not be updated. + */ + public function writeFiles(): void { + // Write composer.json. + $composer_json = Factory::getComposerFile(); + $composer_content = $this->getComposerManipulator()->getContents(); + if (!file_put_contents($composer_json, $composer_content)) { + throw new \RuntimeException(sprintf('Could not update %s', $composer_json)); + } + + // Create package lists for lock file update. + $local_repo = $this->composer->getRepositoryManager()->getLocalRepository(); + $packages = $dev_packages = []; + $dev_package_names = $local_repo->getDevPackageNames(); + foreach ($local_repo->getPackages() as $package) { + if (in_array($package->getName(), $dev_package_names, TRUE)) { + $dev_packages[] = $package; + } + else { + $packages[] = $package; + } + } + + $lock_file_path = Factory::getLockFile(Factory::getComposerFile()); + $lock_file = new JsonFile($lock_file_path, io: $this->io); + $old_locker = $this->composer->getLocker(); + $locker = new Locker($this->io, $lock_file, $this->composer->getInstallationManager(), $composer_content); + $composer_locker_content = $this->getComposerLockedContent(); + + // Write the lock file. + $locker->setLockData( + $packages, + $dev_packages, + $composer_locker_content['platform'], + $composer_locker_content['platform-dev'], + $composer_locker_content['aliases'], + $old_locker->getMinimumStability(), + $old_locker->getStabilityFlags(), + $old_locker->getPreferStable(), + $old_locker->getPreferLowest(), + $old_locker->getPlatformOverrides(), + ); + $this->composer->setLocker($locker); + + // Update installed.json and installed.php. + $local_repo->write($local_repo->getDevMode() ?? TRUE, $this->composer->getInstallationManager()); + + $this->io->write("Unpacking has updated the root composer files.", verbosity: IOInterface::VERBOSE); + + assert(self::checkRootPackage($composer_content, $this->composer->getPackage()), 'Composer root package and composer.json match'); + } + + /** + * Checks that the composer content and root package match. + * + * @param string $composer_content + * The root composer content. + * @param \Composer\Package\RootPackageInterface $root_package + * The root package. + * + * @return bool + * TRUE if the composer content and root package match, FALSE if not. + */ + private static function checkRootPackage(string $composer_content, RootPackageInterface $root_package): bool { + $composer = JsonFile::parseJson($composer_content); + return empty(array_diff_key($root_package->getRequires(), $composer['require'] ?? [])) && empty(array_diff_key($root_package->getDevRequires(), $composer['require-dev'] ?? [])); + } + +} diff --git a/composer/Plugin/RecipeUnpack/SemVer.php b/composer/Plugin/RecipeUnpack/SemVer.php new file mode 100644 index 000000000000..95a7862d8adb --- /dev/null +++ b/composer/Plugin/RecipeUnpack/SemVer.php @@ -0,0 +1,64 @@ +<?php + +namespace Drupal\Composer\Plugin\RecipeUnpack; + +use Composer\Semver\Constraint\MatchNoneConstraint; +use Composer\Semver\Constraint\MultiConstraint; +use Composer\Semver\Intervals; +use Composer\Semver\VersionParser; + +/** + * Helper class to manipulate semantic versioning constraints. + * + * @internal + */ +final class SemVer { + + private function __construct() {} + + /** + * Minimizes two constraints. + * + * Compares two constraints and determines if one is a subset of the other. If + * this is the case, the constraint that is a subset is returned. For example, + * if called with '^6.2' and '^6.3' the function will return '^6.3'. If + * neither constraint is a subset then the constraints are compacted and the + * intersection is returned. For example, if called with ">=10.3" and + * "^10.4 || ^11" the function will return ">=10.4.0.0-dev, <12.0.0.0-dev". + * + * @param \Composer\Semver\VersionParser $version_parser + * A version parser. + * @param string $constraint_a + * A constraint to compact. + * @param string $constraint_b + * A constraint to compact. + * + * @return string + * The compacted constraint. + * + * @throws \LogicException + * Thrown when the provided constraints have no intersection. + */ + public static function minimizeConstraints(VersionParser $version_parser, string $constraint_a, string $constraint_b): string { + $constraint_object_a = $version_parser->parseConstraints($constraint_a); + $constraint_object_b = $version_parser->parseConstraints($constraint_b); + if (Intervals::isSubsetOf($constraint_object_a, $constraint_object_b)) { + return $constraint_a; + } + if (Intervals::isSubsetOf($constraint_object_b, $constraint_object_a)) { + return $constraint_b; + } + $constraint = Intervals::compactConstraint(new MultiConstraint([$constraint_object_a, $constraint_object_b])); + if ($constraint instanceof MatchNoneConstraint) { + throw new \LogicException(sprintf('The constraints "%s" and "%s" do not intersect and cannot be minimized.', $constraint_a, $constraint_b)); + } + return sprintf( + '%s%s, %s%s', + $constraint->getLowerBound()->isInclusive() ? '>=' : '>', + $constraint->getLowerBound()->getVersion(), + $constraint->getUpperBound()->isInclusive() ? '<=' : '<', + $constraint->getUpperBound()->getVersion() + ); + } + +} diff --git a/composer/Plugin/RecipeUnpack/UnpackCollection.php b/composer/Plugin/RecipeUnpack/UnpackCollection.php new file mode 100644 index 000000000000..359ed84f007e --- /dev/null +++ b/composer/Plugin/RecipeUnpack/UnpackCollection.php @@ -0,0 +1,104 @@ +<?php + +namespace Drupal\Composer\Plugin\RecipeUnpack; + +use Composer\Package\PackageInterface; + +/** + * A collection with packages to unpack. + * + * @internal + */ +final class UnpackCollection implements \Iterator, \Countable { + + /** + * The queue of packages to unpack. + * + * @var \Composer\Package\PackageInterface[] + */ + private array $packagesToUnpack = []; + + /** + * The list of packages that have been unpacked. + * + * @var array<string, \Composer\Package\PackageInterface> + */ + private array $unpackedPackages = []; + + /** + * {@inheritdoc} + */ + public function rewind(): void { + reset($this->packagesToUnpack); + } + + /** + * {@inheritdoc} + */ + public function current(): PackageInterface|false { + return current($this->packagesToUnpack); + } + + /** + * {@inheritdoc} + */ + public function key(): ?string { + return key($this->packagesToUnpack); + } + + /** + * {@inheritdoc} + */ + public function next(): void { + next($this->packagesToUnpack); + } + + /** + * {@inheritdoc} + */ + public function valid(): bool { + return current($this->packagesToUnpack) !== FALSE; + } + + /** + * {@inheritdoc} + */ + public function count(): int { + return count($this->packagesToUnpack); + } + + /** + * Adds a package to the queue of packages to unpack. + * + * @param \Composer\Package\PackageInterface $package + * The package to add to the queue. + */ + public function add(PackageInterface $package): self { + $this->packagesToUnpack[$package->getUniqueName()] = $package; + return $this; + } + + /** + * Marks a package as unpacked. + * + * @param \Composer\Package\PackageInterface $package + * The package that has been unpacked. + */ + public function markPackageUnpacked(PackageInterface $package): void { + $this->unpackedPackages[$package->getUniqueName()] = $package; + } + + /** + * Checks if a package has been unpacked, or it's queued for unpacking. + * + * @param \Composer\Package\PackageInterface $package + * The package to check. + * + * @return bool + * TRUE if the package has been unpacked. + */ + public function isUnpacked(PackageInterface $package): bool { + return isset($this->unpackedPackages[$package->getUniqueName()]); + } + +} diff --git a/composer/Plugin/RecipeUnpack/UnpackCommand.php b/composer/Plugin/RecipeUnpack/UnpackCommand.php new file mode 100644 index 000000000000..60bd86f08c17 --- /dev/null +++ b/composer/Plugin/RecipeUnpack/UnpackCommand.php @@ -0,0 +1,105 @@ +<?php + +namespace Drupal\Composer\Plugin\RecipeUnpack; + +use Composer\Command\BaseCommand; +use Composer\Package\PackageInterface; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * The "drupal:recipe-unpack" command class. + * + * Manually run the unpack operation that normally happens after + * 'composer require'. + * + * @internal + */ +final class UnpackCommand extends BaseCommand { + + /** + * {@inheritdoc} + */ + protected function configure(): void { + $name = 'drupal:recipe-unpack'; + $this + ->setName($name) + ->setDescription('Unpack Drupal recipes.') + ->addArgument('recipes', InputArgument::IS_ARRAY, "A list of recipe package names separated by a space, e.g. drupal/recipe_one drupal/recipe_two. If not provided, all recipes listed in the require section of the root composer are unpacked.") + ->setHelp( + <<<EOT +The <info>$name</info> command unpacks dependencies from the specified recipe +packages into the composer.json file. + +<info>php composer.phar $name drupal/my-recipe [...]</info> + +It is usually not necessary to call <info>$name</info> manually, +because by default it is called automatically as needed, after a +<info>require</info> command. +EOT + ); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $composer = $this->requireComposer(); + $io = $this->getIO(); + $local_repo = $composer->getRepositoryManager()->getLocalRepository(); + $package_names = $input->getArgument('recipes') ?? []; + + // If no recipes are provided unpack all recipes that are required by the + // root package. + if (empty($package_names)) { + foreach ($composer->getPackage()->getRequires() as $link) { + $package = $local_repo->findPackage($link->getTarget(), $link->getConstraint()); + if ($package->getType() === Plugin::RECIPE_PACKAGE_TYPE) { + $package_names[] = $package->getName(); + } + } + if (empty($package_names)) { + $io->write('<info>No recipes to unpack.</info>'); + return 0; + } + } + + $manager = new UnpackManager($composer, $io); + $unpack_collection = new UnpackCollection(); + foreach ($package_names as $package_name) { + if (!$manager->isRootDependency($package_name)) { + $io->error(sprintf('<info>%s</info> not found in the root composer.json.', $package_name)); + return 1; + } + $packages = $local_repo->findPackages($package_name); + $package = reset($packages); + + if (!$package instanceof PackageInterface) { + $io->error(sprintf('<info>%s</info> does not resolve to a package.', $package_name)); + return 1; + } + + if ($package->getType() !== Plugin::RECIPE_PACKAGE_TYPE) { + $io->error(sprintf('<info>%s</info> is not a recipe.', $package->getPrettyName())); + return 1; + } + + if ($manager->unpackOptions->isIgnored($package)) { + $io->error(sprintf('<info>%s</info> is in the extra.drupal-recipe-unpack.ignore list.', $package->getName())); + return 1; + } + + if (UnpackManager::isDevRequirement($package)) { + $io->warning(sprintf('<info>%s</info> is present in the require-dev key. Unpacking will move the recipe\'s dependencies to the require key.', $package->getName())); + if ($io->isInteractive() && !$io->askConfirmation('<info>Do you want to continue</info> [<comment>yes</comment>]?')) { + return 0; + } + } + $unpack_collection->add($package); + } + $manager->unpack($unpack_collection); + return 0; + } + +} diff --git a/composer/Plugin/RecipeUnpack/UnpackManager.php b/composer/Plugin/RecipeUnpack/UnpackManager.php new file mode 100644 index 000000000000..cf61b75e1313 --- /dev/null +++ b/composer/Plugin/RecipeUnpack/UnpackManager.php @@ -0,0 +1,99 @@ +<?php + +namespace Drupal\Composer\Plugin\RecipeUnpack; + +use Composer\Composer; +use Composer\InstalledVersions; +use Composer\IO\IOInterface; +use Composer\Package\PackageInterface; +use Composer\Semver\Intervals; + +/** + * Manages the recipe unpacking process. + * + * @internal + */ +final readonly class UnpackManager { + + /** + * The root composer with the root dependencies to be manipulated. + */ + private RootComposer $rootComposer; + + /** + * The unpack options. + */ + public UnpackOptions $unpackOptions; + + public function __construct( + private Composer $composer, + private IOInterface $io, + ) { + $this->rootComposer = new RootComposer($composer, $io); + $this->unpackOptions = UnpackOptions::create($composer->getPackage()->getExtra()); + } + + /** + * Unpacks the packages in the provided collection. + * + * @param \Drupal\Composer\Plugin\RecipeUnpack\UnpackCollection $unpackCollection + * The collection of recipe packages to unpack. + */ + public function unpack(UnpackCollection $unpackCollection): void { + if (count($unpackCollection) === 0) { + // Early return to avoid unnecessary work. + return; + } + + foreach ($unpackCollection as $package) { + $unpacker = new Unpacker( + $package, + $this->composer, + $this->rootComposer, + $unpackCollection, + $this->unpackOptions, + $this->io, + ); + $unpacker->unpackDependencies(); + $this->io->write("<info>{$package->getName()}</info> unpacked."); + } + + // Unpacking uses \Composer\Semver\Intervals::isSubsetOf() to choose between + // constraints. + Intervals::clear(); + + $this->rootComposer->writeFiles(); + } + + /** + * Determines if the provided package is present in the root composer.json. + * + * @param string $package_name + * The package name to check. + * + * @return bool + * TRUE if the package is present in the root composer.json, FALSE if not. + */ + public function isRootDependency(string $package_name): bool { + $root_package = $this->composer->getPackage(); + return isset($root_package->getRequires()[$package_name]) || isset($root_package->getDevRequires()[$package_name]); + } + + /** + * Checks if a package is a dev requirement. + * + * @param \Composer\Package\PackageInterface $package + * The package to check. + * + * @return bool + * TRUE if the package is present in require-dev or due to a package in + * require-dev, FALSE if not. + */ + public static function isDevRequirement(PackageInterface $package): bool { + // Check if package is either a regular or dev requirement. + return InstalledVersions::isInstalled($package->getName()) && + // Check if package is a regular requirement. + !InstalledVersions::isInstalled($package->getName(), FALSE); + } + +} diff --git a/composer/Plugin/RecipeUnpack/UnpackOptions.php b/composer/Plugin/RecipeUnpack/UnpackOptions.php new file mode 100644 index 000000000000..ea479ee587e1 --- /dev/null +++ b/composer/Plugin/RecipeUnpack/UnpackOptions.php @@ -0,0 +1,79 @@ +<?php + +namespace Drupal\Composer\Plugin\RecipeUnpack; + +use Composer\Package\PackageInterface; + +/** + * Per-project options from the 'extras' section of the composer.json file. + * + * Projects that implement dependency unpacking plugin can further configure it. + * This data is pulled from the 'drupal-recipe-unpack' portion of the extras + * section. + * + * @code + * "extras": { + * "drupal-recipe-unpack": { + * "ignore": ["drupal/recipe_name"], + * "on-require": true + * } + * } + * @endcode + * + * Supported options: + * - `ignore` (array): + * Specifies packages to exclude from unpacking into the root composer.json. + * - `on-require` (boolean): + * Whether to unpack recipes automatically on require. + * + * @internal + */ +final readonly class UnpackOptions { + + /** + * The ID of the extra section in the top-level composer.json file. + */ + const string ID = 'drupal-recipe-unpack'; + + /** + * The raw data from the 'extras' section of the top-level composer.json file. + * + * @var array{ignore: string[], on-require: boolean} + */ + public array $options; + + private function __construct(array $options) { + $this->options = $options + [ + 'ignore' => [], + 'on-require' => TRUE, + ]; + } + + /** + * Checks if a package should be ignored. + * + * @param \Composer\Package\PackageInterface $package + * The package. + * + * @return bool + * TRUE if the package should be ignored, FALSE if not. + */ + public function isIgnored(PackageInterface $package): bool { + return in_array($package->getName(), $this->options['ignore'], TRUE); + } + + /** + * Creates an unpack options object. + * + * @param array $extras + * The contents of the 'extras' section. + * + * @return self + * The unpack options object representing the provided unpack options + */ + public static function create(array $extras): self { + $options = $extras[self::ID] ?? []; + return new self($options); + } + +} diff --git a/composer/Plugin/RecipeUnpack/Unpacker.php b/composer/Plugin/RecipeUnpack/Unpacker.php new file mode 100644 index 000000000000..5b1613ee777c --- /dev/null +++ b/composer/Plugin/RecipeUnpack/Unpacker.php @@ -0,0 +1,217 @@ +<?php + +namespace Drupal\Composer\Plugin\RecipeUnpack; + +use Composer\Composer; +use Composer\IO\IOInterface; +use Composer\Package\Link; +use Composer\Package\PackageInterface; +use Composer\Semver\VersionParser; + +/** + * Handles the details of unpacking a specific recipe. + * + * @internal + */ +final readonly class Unpacker { + + /** + * The version parser. + */ + private VersionParser $versionParser; + + public function __construct( + private PackageInterface $package, + private Composer $composer, + private RootComposer $rootComposer, + private UnpackCollection $unpackCollection, + private UnpackOptions $unpackOptions, + private IOInterface $io, + ) { + $this->versionParser = new VersionParser(); + } + + /** + * Unpacks the package's dependencies to the root composer.json and lock file. + */ + public function unpackDependencies(): void { + $this->updateComposerJsonPackages(); + $this->updateComposerLockContent(); + $this->unpackCollection->markPackageUnpacked($this->package); + } + + /** + * Processes dependencies of the package that is being unpacked. + * + * If the dependency is a recipe and should be unpacked, we add it into the + * package queue so that it will be unpacked as well. If the dependency is not + * a recipe, or an ignored recipe, the package link will be yielded. + * + * @param array<string, \Composer\Package\Link> $package_dependency_links + * The package dependencies to process. + * + * @return iterable<\Composer\Package\Link> + * The package dependencies to add to composer.json. + */ + private function processPackageDependencies(array $package_dependency_links): iterable { + foreach ($package_dependency_links as $link) { + if ($link->getTarget() === $this->package->getName()) { + // This dependency is the same as the current package, so let's skip it. + continue; + } + + $package = $this->getPackageFromLinkTarget($link); + + // If we can't find the package in the local repository that's because it + // has already been removed therefore skip it. + if ($package === NULL) { + continue; + } + + if ($package->getType() === Plugin::RECIPE_PACKAGE_TYPE) { + if ($this->unpackCollection->isUnpacked($package)) { + // This dependency is already unpacked. + continue; + } + + if (!$this->unpackOptions->isIgnored($package)) { + // This recipe should be unpacked as well. + $this->unpackCollection->add($package); + continue; + } + else { + // This recipe should not be unpacked. But it might need to be added + // to the root composer.json + $this->io->write(sprintf('<info>%s</info> not unpacked because it is ignored.', $package->getName()), verbosity: IOInterface::VERBOSE); + } + } + + yield $link; + } + } + + /** + * Updates the composer.json content with the package being unpacked. + * + * This method will add all the package dependencies to the root composer.json + * content and also remove the package itself from the root composer.json. + * + * @throws \RuntimeException + * If the composer.json could not be updated. + */ + private function updateComposerJsonPackages(): void { + $composer_manipulator = $this->rootComposer->getComposerManipulator(); + $composer_config = $this->composer->getConfig(); + $sort_packages = $composer_config->get('sort-packages'); + $root_package = $this->composer->getPackage(); + $root_requires = $root_package->getRequires(); + $root_dev_requires = $root_package->getDevRequires(); + + foreach ($this->processPackageDependencies($this->package->getRequires()) as $package_dependency) { + $dependency_name = $package_dependency->getTarget(); + $recipe_constraint_string = $package_dependency->getPrettyConstraint(); + if (isset($root_requires[$dependency_name])) { + $recipe_constraint_string = SemVer::minimizeConstraints($this->versionParser, $recipe_constraint_string, $root_requires[$dependency_name]->getPrettyConstraint()); + if ($recipe_constraint_string === $root_requires[$dependency_name]) { + // This dependency is already in the required section with the + // correct constraint. + continue; + } + } + elseif (isset($root_dev_requires[$dependency_name])) { + $recipe_constraint_string = SemVer::minimizeConstraints($this->versionParser, $recipe_constraint_string, $root_dev_requires[$dependency_name]->getPrettyConstraint()); + // This dependency is already in the require-dev section. We will + // move it to the require section. + $composer_manipulator->removeSubNode('require-dev', $dependency_name); + } + + // Add the dependency to the required section. If it cannot be added, then + // throw an exception. + if (!$composer_manipulator->addLink( + 'require', + $dependency_name, + $recipe_constraint_string, + $sort_packages, + )) { + throw new \RuntimeException(sprintf('Unable to manipulate composer.json during the unpack of %s', + $dependency_name, + )); + } + $link = new Link($root_package->getName(), $dependency_name, $this->versionParser->parseConstraints($recipe_constraint_string), Link::TYPE_REQUIRE, $recipe_constraint_string); + $root_requires[$dependency_name] = $link; + unset($root_dev_requires[$dependency_name]); + $this->io->write(sprintf('Adding <info>%s</info> (<comment>%s</comment>) to composer.json during the unpack of <info>%s</info>', $dependency_name, $recipe_constraint_string, $this->package->getName()), verbosity: IOInterface::VERBOSE); + } + + // Ensure the written packages are no longer in the dev package names. + $local_repo = $this->composer->getRepositoryManager()->getLocalRepository(); + $local_repo->setDevPackageNames(array_diff($local_repo->getDevPackageNames(), array_keys($root_requires))); + + // Update the root package to reflect the changes. + $root_package->setDevRequires($root_dev_requires); + $root_package->setRequires($root_requires); + + $composer_manipulator->removeSubNode(UnpackManager::isDevRequirement($this->package) ? 'require-dev' : 'require', $this->package->getName()); + $this->io->write(sprintf('Removing <info>%s</info> from composer.json', $this->package->getName()), verbosity: IOInterface::VERBOSE); + + $composer_manipulator->removeMainKeyIfEmpty('require-dev'); + } + + /** + * Updates the composer.lock content and keeps the local repo in sync. + * + * This method will remove the package itself from the composer.lock content + * in the root composer. + */ + private function updateComposerLockContent(): void { + $composer_locker_content = $this->rootComposer->getComposerLockedContent(); + $root_package = $this->composer->getPackage(); + $root_requires = $root_package->getRequires(); + $root_dev_requires = $root_package->getDevRequires(); + $local_repo = $this->composer->getRepositoryManager()->getLocalRepository(); + + if (isset($root_requires[$this->package->getName()])) { + unset($root_requires[$this->package->getName()]); + $root_package->setRequires($root_requires); + } + + foreach ($composer_locker_content['packages'] as $key => $lock_data) { + // Find the package being unpacked in the composer.lock content and + // remove it. + if ($lock_data['name'] === $this->package->getName()) { + $this->rootComposer->removeFromComposerLock('packages', $key); + // If the package is in require-dev we need to move the lock data. + if (isset($root_dev_requires[$lock_data['name']])) { + $this->rootComposer->addToComposerLock('packages-dev', $lock_data); + $dev_package_names = $local_repo->getDevPackageNames(); + $dev_package_names[] = $lock_data['name']; + $local_repo->setDevPackageNames($dev_package_names); + return; + } + break; + } + } + $local_repo->setDevPackageNames(array_diff($local_repo->getDevPackageNames(), [$this->package->getName()])); + $local_repo->removePackage($this->package); + if (isset($root_dev_requires[$this->package->getName()])) { + unset($root_dev_requires[$this->package->getName()]); + $root_package->setDevRequires($root_dev_requires); + } + } + + /** + * Gets the package object from a link's target. + * + * @param \Composer\Package\Link $dependency + * The link dependency. + * + * @return \Composer\Package\PackageInterface|null + * The package object. + */ + private function getPackageFromLinkTarget(Link $dependency): ?PackageInterface { + return $this->composer->getRepositoryManager() + ->getLocalRepository() + ->findPackage($dependency->getTarget(), $dependency->getConstraint()); + } + +} diff --git a/composer/Plugin/RecipeUnpack/composer.json b/composer/Plugin/RecipeUnpack/composer.json new file mode 100644 index 000000000000..61fb20f75bdc --- /dev/null +++ b/composer/Plugin/RecipeUnpack/composer.json @@ -0,0 +1,26 @@ +{ + "name": "drupal/core-recipe-unpack", + "description": "A Composer project unpacker for Drupal recipes.", + "type": "composer-plugin", + "keywords": ["drupal"], + "homepage": "https://www.drupal.org/project/drupal", + "license": "GPL-2.0-or-later", + "require": { + "composer-plugin-api": "^2", + "php": ">=8.3" + }, + "autoload": { + "psr-4": { + "Drupal\\Composer\\Plugin\\RecipeUnpack\\": "" + } + }, + "extra": { + "class": "Drupal\\Composer\\Plugin\\RecipeUnpack\\Plugin" + }, + "config": { + "sort-packages": true + }, + "require-dev": { + "composer/composer": "^2.7" + } +} |