Unverified Commit 3bb50b2b authored by larowlan's avatar larowlan

Issue #2982684 by greg.1.anderson, Mile23, Mixologic, webflo, alexpott,...

Issue #2982684 by greg.1.anderson, Mile23, Mixologic, webflo, alexpott, yogeshmpawar, pingwin4eg, vijaycs85, larowlan, dww, borisson_, phenaproxima, kim.pepper, bojanz, grasmash, hctom, kmbremner, pingers, Jax, sherakama, derhasi, claudiu.cristea, jhedstrom, Xano, Grimreaper: Add a composer scaffolding plugin to core
parent c1734c61
......@@ -110,6 +110,7 @@
"drupal/core-proxy-builder": "self.version",
"drupal/core-render": "self.version",
"drupal/core-serialization": "self.version",
"drupal/core-composer-scaffold": "self.version",
"drupal/core-transliteration": "self.version",
"drupal/core-utility": "self.version",
"drupal/core-uuid": "self.version",
......@@ -201,6 +202,7 @@
"core/lib/Drupal/Component/ProxyBuilder/composer.json",
"core/lib/Drupal/Component/Render/composer.json",
"core/lib/Drupal/Component/Serialization/composer.json",
"core/lib/Drupal/Component/Scaffold/composer.json",
"core/lib/Drupal/Component/Transliteration/composer.json",
"core/lib/Drupal/Component/Utility/composer.json",
"core/lib/Drupal/Component/Uuid/composer.json",
......
<?php
namespace Drupal\Component\Scaffold;
use Composer\Composer;
use Composer\Installer\PackageEvent;
use Composer\IO\IOInterface;
use Composer\Package\PackageInterface;
/**
* Determine recursively which packages have been allowed to scaffold files.
*
* If the root-level composer.json allows drupal/core, and drupal/core allows
* drupal/assets, then the later package will also implicitly be allowed.
*/
class AllowedPackages implements PostPackageEventListenerInterface {
/**
* The Composer service.
*
* @var \Composer\Composer
*/
protected $composer;
/**
* Composer's I/O service.
*
* @var \Composer\IO\IOInterface
*/
protected $io;
/**
* Manager of the options in the top-level composer.json's 'extra' section.
*
* @var \Drupal\Component\Scaffold\ManageOptions
*/
protected $manageOptions;
/**
* The list of new packages added by this Composer command.
*
* @var array
*/
protected $newPackages = [];
/**
* AllowedPackages constructor.
*
* @param \Composer\Composer $composer
* The composer object.
* @param \Composer\IO\IOInterface $io
* IOInterface to write to.
* @param \Drupal\Component\Scaffold\ManageOptions $manage_options
* Manager of the options in the top-level composer.json's 'extra' section.
*/
public function __construct(Composer $composer, IOInterface $io, ManageOptions $manage_options) {
$this->composer = $composer;
$this->io = $io;
$this->manageOptions = $manage_options;
}
/**
* Gets a list of all packages that are allowed to copy scaffold files.
*
* Configuration for packages specified later will override configuration
* specified by packages listed earlier. In other words, the last listed
* package has the highest priority. The root package will always be returned
* at the end of the list.
*
* @return \Composer\Package\PackageInterface[]
* An array of allowed Composer packages.
*/
public function getAllowedPackages() {
$options = $this->manageOptions->getOptions();
$allowed_packages = $this->recursiveGetAllowedPackages($options->allowedPackages());
// If the root package defines any file mappings, then implicitly add it
// to the list of allowed packages. Add it at the end so that it overrides
// all the preceding packages.
if ($options->hasFileMapping()) {
$root_package = $this->composer->getPackage();
unset($allowed_packages[$root_package->getName()]);
$allowed_packages[$root_package->getName()] = $root_package;
}
// Handle any newly-added packages that are not already allowed.
return $this->evaluateNewPackages($allowed_packages);
}
/**
* {@inheritdoc}
*/
public function event(PackageEvent $event) {
$operation = $event->getOperation();
// Determine the package.
$package = $operation->getJobType() == 'update' ? $operation->getTargetPackage() : $operation->getPackage();
if (ScaffoldOptions::hasOptions($package->getExtra())) {
$this->newPackages[$package->getName()] = $package;
}
}
/**
* Builds a name-to-package mapping from a list of package names.
*
* @param string[] $packages_to_allow
* List of package names to allow.
* @param array $allowed_packages
* Mapping of package names to PackageInterface of packages already
* accumulated.
*
* @return \Composer\Package\PackageInterface[]
* Mapping of package names to PackageInterface in priority order.
*/
protected function recursiveGetAllowedPackages(array $packages_to_allow, array $allowed_packages = []) {
foreach ($packages_to_allow as $name) {
$package = $this->getPackage($name);
if ($package instanceof PackageInterface && !isset($allowed_packages[$name])) {
$allowed_packages[$name] = $package;
$package_options = $this->manageOptions->packageOptions($package);
$allowed_packages = $this->recursiveGetAllowedPackages($package_options->allowedPackages(), $allowed_packages);
}
}
return $allowed_packages;
}
/**
* Evaluates newly-added packages and see if they are already allowed.
*
* For now we will only emit warnings if they are not.
*
* @param array $allowed_packages
* Mapping of package names to PackageInterface of packages already
* accumulated.
*
* @return \Composer\Package\PackageInterface[]
* Mapping of package names to PackageInterface in priority order.
*/
protected function evaluateNewPackages(array $allowed_packages) {
foreach ($this->newPackages as $name => $newPackage) {
if (!array_key_exists($name, $allowed_packages)) {
$this->io->write("Not scaffolding files for <comment>{$name}</comment>, because it is not listed in the element 'extra.composer-scaffold.allowed-packages' in the root-level composer.json file.");
}
else {
$this->io->write("Package <comment>{$name}</comment> has scaffold operations, and is already allowed in the root-level composer.json file.");
}
}
// @todo We could prompt the user and ask if they wish to allow a
// newly-added package. This might be useful if, for example, the user
// might wish to require an installation profile that contains scaffolded
// assets. For more information, see:
// https://www.drupal.org/project/drupal/issues/3064990
return $allowed_packages;
}
/**
* Retrieves a package from the current composer process.
*
* @param string $name
* Name of the package to get from the current composer installation.
*
* @return \Composer\Package\PackageInterface|null
* The Composer package.
*/
protected function getPackage($name) {
return $this->composer->getRepositoryManager()->getLocalRepository()->findPackage($name, '*');
}
}
<?php
namespace Drupal\Component\Scaffold;
use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability;
/**
* List of all commands provided by this package.
*/
class CommandProvider implements CommandProviderCapability {
/**
* {@inheritdoc}
*/
public function getCommands() {
return [new ComposerScaffoldCommand()];
}
}
<?php
namespace Drupal\Component\Scaffold;
use Composer\Command\BaseCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* The "composer:scaffold" command class.
*
* Manually run the scaffold operation that normally happens after
* 'composer install'.
*/
class ComposerScaffoldCommand extends BaseCommand {
/**
* {@inheritdoc}
*/
protected function configure() {
$this
->setName('composer:scaffold')
->setDescription('Update the Composer scaffold files.')
->setHelp(
<<<EOT
The <info>composer:scaffold</info> command places the scaffold files in their
respective locations according to the layout stipulated in the composer.json
file.
<info>php composer.phar composer:scaffold</info>
It is usually not necessary to call <info>composer:scaffold</info> manually,
because it is called automatically as needed, e.g. after an <info>install</info>
or <info>update</info> command. Note, though, that only packages explicitly
allowed to scaffold in the top-level composer.json will be processed by this
command.
For more information, see https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold.
EOT
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output) {
$handler = new Handler($this->getComposer(), $this->getIO());
$handler->scaffold();
}
}
<?php
namespace Drupal\Component\Scaffold;
use Composer\IO\IOInterface;
use Composer\Util\Filesystem;
use Drupal\Component\Scaffold\Operations\ScaffoldResult;
/**
* Generates an 'autoload.php' that includes the autoloader created by Composer.
*/
final class GenerateAutoloadReferenceFile {
/**
* This class provides only static methods.
*/
private function __construct() {
}
/**
* Generates the autoload file at the specified location.
*
* This only writes a bit of PHP that includes the autoload file that
* Composer generated. Drupal does this so that it can guarantee that there
* will always be an `autoload.php` file in a well-known location.
*
* @param \Composer\IO\IOInterface $io
* IOInterface to write to.
* @param string $package_name
* The name of the package defining the autoload file (the root package).
* @param string $web_root
* The path to the web root.
* @param string $vendor
* The path to the vendor directory.
*
* @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
* The result of the autoload file generation.
*/
public static function generateAutoload(IOInterface $io, $package_name, $web_root, $vendor) {
$autoload_path = static::autoloadPath($package_name, $web_root);
$location = dirname($autoload_path->fullPath());
// Calculate the relative path from the webroot (location of the project
// autoload.php) to the vendor directory.
$fs = new Filesystem();
$relative_vendor_path = $fs->findShortestPath(realpath($location), $vendor);
file_put_contents($autoload_path->fullPath(), static::autoLoadContents($relative_vendor_path));
return new ScaffoldResult($autoload_path, TRUE);
}
/**
* Determines whether or not the autoload file has been committed.
*
* @param \Composer\IO\IOInterface $io
* IOInterface to write to.
* @param string $package_name
* The name of the package defining the autoload file (the root package).
* @param string $web_root
* The path to the web root.
*
* @return bool
* True if autoload.php file exists and has been committed to the repository
*/
public static function autoloadFileCommitted(IOInterface $io, $package_name, $web_root) {
$autoload_path = static::autoloadPath($package_name, $web_root);
$location = dirname($autoload_path->fullPath());
if (!file_exists($location)) {
return FALSE;
}
return Git::checkTracked($io, $location, $location);
}
/**
* Generates a scaffold file path object for the autoload file.
*
* @param string $package_name
* The name of the package defining the autoload file (the root package).
* @param string $web_root
* The path to the web root.
*
* @return \Drupal\Component\Scaffold\ScaffoldFilePath
* Object wrapping the relative and absolute path to the destination file.
*/
protected static function autoloadPath($package_name, $web_root) {
$rel_path = 'autoload.php';
$dest_rel_path = '[web-root]/' . $rel_path;
$dest_full_path = $web_root . '/' . $rel_path;
return new ScaffoldFilePath('autoload', $package_name, $dest_rel_path, $dest_full_path);
}
/**
* Builds the contents of the autoload file.
*
* @param string $vendor_path
* The relative path to vendor.
*
* @return string
* Return the contents for the autoload.php.
*/
protected static function autoLoadContents($vendor_path) {
$vendor_path = rtrim($vendor_path, '/');
return <<<EOF
<?php
/**
* @file
* Includes the autoloader created by Composer.
*
* This file was generated by composer-scaffold.
*.
* @see composer.json
* @see index.php
* @see core/install.php
* @see core/rebuild.php
* @see core/modules/statistics/statistics.php
*/
return require __DIR__ . '/{$vendor_path}/autoload.php';
EOF;
}
}
<?php
namespace Drupal\Component\Scaffold;
use Composer\IO\IOInterface;
use Composer\Util\ProcessExecutor;
/**
* Provide some Git utility operations
*/
class Git {
/**
* This class provides only static methods.
*/
private function __construct() {
}
/**
* Determines whether the specified scaffold file is already ignored.
*
* @param string $path
* Path to scaffold file to check.
* @param string $dir
* Base directory for git process.
*
* @return bool
* Whether the specified file is already ignored or not (TRUE if ignored).
*/
public static function checkIgnore(IOInterface $io, $path, $dir = NULL) {
$process = new ProcessExecutor($io);
$output = '';
$exitCode = $process->execute('git check-ignore ' . $process->escape($path), $output, $dir);
return $exitCode == 0;
}
/**
* Determines whether the specified scaffold file is tracked by git.
*
* @param string $path
* Path to scaffold file to check.
* @param string $dir
* Base directory for git process.
*
* @return bool
* Whether the specified file is already tracked or not (TRUE if tracked).
*/
public static function checkTracked(IOInterface $io, $path, $dir = NULL) {
$process = new ProcessExecutor($io);
$output = '';
$exitCode = $process->execute('git ls-files --error-unmatch ' . $process->escape($path), $output, $dir);
return $exitCode == 0;
}
/**
* Checks to see if the project root dir is in a git repository.
*
* @param string $dir
* Base directory for git process.
* @return bool
* True if this is a repository.
*/
public static function isRepository(IOInterface $io, $dir = NULL) {
$process = new ProcessExecutor($io);
$output = '';
$exitCode = $process->execute('git rev-parse --show-toplevel', $output, $dir);
return $exitCode == 0;
}
}
<?php
namespace Drupal\Component\Scaffold;
use Composer\Composer;
use Composer\EventDispatcher\EventDispatcher;
use Composer\Installer\PackageEvent;
use Composer\IO\IOInterface;
use Composer\Package\PackageInterface;
use Composer\Plugin\CommandEvent;
use Composer\Util\Filesystem;
use Drupal\Component\Scaffold\Operations\OperationData;
use Drupal\Component\Scaffold\Operations\OperationFactory;
use Drupal\Component\Scaffold\Operations\ScaffoldFileCollection;
/**
* Core class of the plugin.
*
* Contains the primary logic which determines the files to be fetched and
* processed.
*/
class Handler {
/**
* Composer hook called before scaffolding begins.
*/
const PRE_COMPOSER_SCAFFOLD_CMD = 'pre-composer-scaffold-cmd';
/**
* Composer hook called after scaffolding completes.
*/
const POST_COMPOSER_SCAFFOLD_CMD = 'post-composer-scaffold-cmd';
/**
* The Composer service.
*
* @var \Composer\Composer
*/
protected $composer;
/**
* Composer's I/O service.
*
* @var \Composer\IO\IOInterface
*/
protected $io;
/**
* The scaffold options in the top-level composer.json's 'extra' section.
*
* @var \Drupal\Component\Scaffold\ManageOptions
*/
protected $manageOptions;
/**
* The manager that keeps track of which packages are allowed to scaffold.
*
* @var \Drupal\Component\Scaffold\AllowedPackages
*/
protected $manageAllowedPackages;
/**
* The list of listeners that are notified after a package event.
*
* @var \Drupal\Component\Scaffold\PostPackageEventListenerInterface[]
*/
protected $postPackageListeners = [];
/**
* Handler constructor.
*
* @param \Composer\Composer $composer
* The Composer service.
* @param \Composer\IO\IOInterface $io
* The Composer I/O service.
*/
public function __construct(Composer $composer, IOInterface $io) {
$this->composer = $composer;
$this->io = $io;
$this->manageOptions = new ManageOptions($composer);
$this->manageAllowedPackages = new AllowedPackages($composer, $io, $this->manageOptions);
}
/**
* Registers post-package events before any 'require' event runs.
*
* This method is called by composer prior to doing a 'require' command.
*
* @param \Composer\Plugin\CommandEvent $event
* The Composer Command event.
*/
public function beforeRequire(CommandEvent $event) {
// In order to differentiate between post-package events called after
// 'composer require' vs. the same events called at other times, we will
// only install our handler when a 'require' event is detected.
$this->postPackageListeners[] = $this->manageAllowedPackages;
}
/**
* Posts package command event.
*
* We want to detect packages 'require'd that have scaffold files, but are not
* yet allowed in the top-level composer.json file.
*
* @param \Composer\Installer\PackageEvent $event
* Composer package event sent on install/update/remove.
*/
public function onPostPackageEvent(PackageEvent $event) {
foreach ($this->postPackageListeners as $listener) {
$listener->event($event);
}
}
/**
* Creates scaffold operation objects for all items in the file mappings.
*
* @param \Composer\Package\PackageInterface $package
* The package that relative paths will be relative from.
* @param array $package_file_mappings
* The package file mappings array keyed by destination path and the values
* are operation metadata arrays.
*
* @return \Drupal\Component\Scaffold\Operations\OperationInterface[]
* A list of scaffolding operation objects
*/
protected function createScaffoldOperations(PackageInterface $package, array $package_file_mappings) {
$scaffold_op_factory = new OperationFactory($this->composer);
$scaffold_ops = [];
foreach ($package_file_mappings as $dest_rel_path => $data) {
$operation_data = new OperationData($dest_rel_path, $data);
$scaffold_ops[$dest_rel_path] = $scaffold_op_factory->create($package, $operation_data);
}
return $scaffold_ops;
}
/**
* Copies all scaffold files from source to destination.
*/
public function scaffold() {
// Recursively get the list of allowed packages. Only allowed packages
// may declare scaffold files. Note that the top-level composer.json file
// is implicitly allowed.
$allowed_packages = $this->manageAllowedPackages->getAllowedPackages();
if (empty($allowed_packages)) {
$this->io->write("Nothing scaffolded because no packages are allowed in the top-level composer.json file.");
return;
}
// Call any pre-scaffold scripts that may be defined.
$dispatcher = new EventDispatcher($this->composer, $this->io);
$dispatcher->dispatch(self::PRE_COMPOSER_SCAFFOLD_CMD);
// Fetch the list of file mappings from each allowed package and normalize
// them.
$file_mappings = $this->getFileMappingsFromPackages($allowed_packages);
$location_replacements = $this->manageOptions->getLocationReplacements();
$scaffold_options = $this->manageOptions->getOptions();
// Create a collection of scaffolded files to process. This determines which
// take priority and which are conjoined.
$scaffold_files = new ScaffoldFileCollection($file_mappings, $location_replacements);
// Process the list of scaffolded files.
$scaffold_results = ScaffoldFileCollection::process($scaffold_files, $this->io, $scaffold_options);
// Generate an autoload file in the document root that includes the
// autoload.php file in the vendor directory, wherever that is. Drupal
// requires this in order to easily locate relocated vendor dirs.
$web_root = $this->manageOptions->getOptions()->getLocation('web-root');
if (!GenerateAutoloadReferenceFile::autoloadFileCommitted($this->io, $this->rootPackageName(), $web_root)) {
$scaffold_results[] = GenerateAutoloadReferenceFile::generateAutoload($this->io, $this->rootPackageName(), $web_root, $this->getVendorPath());
}
// Add the managed scaffold files to .gitignore if applicable.
$gitIgnoreManager = new ManageGitIgnore($this->io, getcwd());
$gitIgnoreManager->manageIgnored($scaffold_results, $scaffold_options);
// Call post-scaffold scripts.
$dispatcher->dispatch(self::POST_COMPOSER_SCAFFOLD_CMD);
}
/**
* Gets the path to the 'vendor' directory.
*
* @return string
* The file path of the vendor directory.
*/
protected function getVendorPath() {
$vendor_dir = $this->composer->getConfig()->get('vendor-dir');
$filesystem = new Filesystem();
return $filesystem->normalizePath(realpath($vendor_dir));
}
/**
* Gets a consolidated list of file mappings from all allowed packages.
*
* @param \Composer\Package\Package[] $allowed_packages
* A multidimensional array of file mappings, as returned by
* self::getAllowedPackages().
*
* @return \Drupal\Component\Scaffold\Operations\OperationInterface[]
* An array of destination paths => scaffold operation objects.
*/
protected function getFileMappingsFromPackages(array $allowed_packages) {
$file_mappings = [];
foreach ($allowed_packages as $package_name => $package) {
$file_mappings[$package_name] = $this->getPackageFileMappings($package);
}
return $file_mappings;
}
/**
* Gets the array of file mappings provided by a given package.
*
* @param \Composer\Package\PackageInterface $package
* The Composer package from which to get the file mappings.
*
* @return \Drupal\Component\Scaffold\Operations\OperationInterface[]
* An array of destination paths => scaffold operation objects.
*/
protected function getPackageFileMappings(PackageInterface $package) {
$options = $this->manageOptions->packageOptions($package);
if ($options->hasFileMapping()) {
return $this->createScaffoldOperations($package, $options->fileMapping());
}
if (!$options->hasAllowedPackages()) {