diff --git a/composer.lock b/composer.lock
index 26bc91aaa4..d21666f2aa 100644
--- a/composer.lock
+++ b/composer.lock
@@ -2060,7 +2060,7 @@
},
{
"name": "Gert de Pagter",
- "email": "backendtea@gmail.com"
+ "email": "BackEndTea@gmail.com"
}
],
"description": "Symfony polyfill for ctype functions",
@@ -3326,6 +3326,246 @@
],
"time": "2018-01-07T19:17:08+00:00"
},
+ {
+ "name": "composer/ca-bundle",
+ "version": "1.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/ca-bundle.git",
+ "reference": "558f321c52faeb4828c03e7dc0cfe39a09e09a2d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/ca-bundle/zipball/558f321c52faeb4828c03e7dc0cfe39a09e09a2d",
+ "reference": "558f321c52faeb4828c03e7dc0cfe39a09e09a2d",
+ "shasum": ""
+ },
+ "require": {
+ "ext-openssl": "*",
+ "ext-pcre": "*",
+ "php": "^5.3.2 || ^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5",
+ "psr/log": "^1.0",
+ "symfony/process": "^2.5 || ^3.0 || ^4.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\CaBundle\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ }
+ ],
+ "description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.",
+ "keywords": [
+ "cabundle",
+ "cacert",
+ "certificate",
+ "ssl",
+ "tls"
+ ],
+ "time": "2019-01-28T09:30:10+00:00"
+ },
+ {
+ "name": "composer/composer",
+ "version": "1.8.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/composer.git",
+ "reference": "949b116f9e7d98d8d276594fed74b580d125c0e6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/composer/zipball/949b116f9e7d98d8d276594fed74b580d125c0e6",
+ "reference": "949b116f9e7d98d8d276594fed74b580d125c0e6",
+ "shasum": ""
+ },
+ "require": {
+ "composer/ca-bundle": "^1.0",
+ "composer/semver": "^1.0",
+ "composer/spdx-licenses": "^1.2",
+ "composer/xdebug-handler": "^1.1",
+ "justinrainbow/json-schema": "^3.0 || ^4.0 || ^5.0",
+ "php": "^5.3.2 || ^7.0",
+ "psr/log": "^1.0",
+ "seld/jsonlint": "^1.4",
+ "seld/phar-utils": "^1.0",
+ "symfony/console": "^2.7 || ^3.0 || ^4.0",
+ "symfony/filesystem": "^2.7 || ^3.0 || ^4.0",
+ "symfony/finder": "^2.7 || ^3.0 || ^4.0",
+ "symfony/process": "^2.7 || ^3.0 || ^4.0"
+ },
+ "conflict": {
+ "symfony/console": "2.8.38"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35 || ^5.7",
+ "phpunit/phpunit-mock-objects": "^2.3 || ^3.0"
+ },
+ "suggest": {
+ "ext-openssl": "Enabling the openssl extension allows you to access https URLs for repositories and packages",
+ "ext-zip": "Enabling the zip extension allows you to unzip archives",
+ "ext-zlib": "Allow gzip compression of HTTP requests"
+ },
+ "bin": [
+ "bin/composer"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.8-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\": "src/Composer"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nils Adermann",
+ "email": "naderman@naderman.de",
+ "homepage": "http://www.naderman.de"
+ },
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ }
+ ],
+ "description": "Composer helps you declare, manage and install dependencies of PHP projects, ensuring you have the right stack everywhere.",
+ "homepage": "https://getcomposer.org/",
+ "keywords": [
+ "autoload",
+ "dependency",
+ "package"
+ ],
+ "time": "2019-04-09T15:46:48+00:00"
+ },
+ {
+ "name": "composer/spdx-licenses",
+ "version": "1.5.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/spdx-licenses.git",
+ "reference": "a1aa51cf3ab838b83b0867b14e56fc20fbd55b3d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/a1aa51cf3ab838b83b0867b14e56fc20fbd55b3d",
+ "reference": "a1aa51cf3ab838b83b0867b14e56fc20fbd55b3d",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.3.2 || ^7.0 || ^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 7"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\Spdx\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nils Adermann",
+ "email": "naderman@naderman.de",
+ "homepage": "http://www.naderman.de"
+ },
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ },
+ {
+ "name": "Rob Bast",
+ "email": "rob.bast@gmail.com",
+ "homepage": "http://robbast.nl"
+ }
+ ],
+ "description": "SPDX licenses list and validation library.",
+ "keywords": [
+ "license",
+ "spdx",
+ "validator"
+ ],
+ "time": "2019-03-26T10:23:26+00:00"
+ },
+ {
+ "name": "composer/xdebug-handler",
+ "version": "1.3.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/xdebug-handler.git",
+ "reference": "46867cbf8ca9fb8d60c506895449eb799db1184f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/46867cbf8ca9fb8d60c506895449eb799db1184f",
+ "reference": "46867cbf8ca9fb8d60c506895449eb799db1184f",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.3.2 || ^7.0",
+ "psr/log": "^1.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Composer\\XdebugHandler\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "John Stevenson",
+ "email": "john-stevenson@blueyonder.co.uk"
+ }
+ ],
+ "description": "Restarts a process without xdebug.",
+ "keywords": [
+ "Xdebug",
+ "performance"
+ ],
+ "time": "2019-05-27T17:52:04+00:00"
+ },
{
"name": "doctrine/instantiator",
"version": "1.0.5",
@@ -5011,6 +5251,99 @@
"homepage": "https://github.com/sebastianbergmann/version",
"time": "2016-10-03T07:35:21+00:00"
},
+ {
+ "name": "seld/jsonlint",
+ "version": "1.7.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Seldaek/jsonlint.git",
+ "reference": "d15f59a67ff805a44c50ea0516d2341740f81a38"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/d15f59a67ff805a44c50ea0516d2341740f81a38",
+ "reference": "d15f59a67ff805a44c50ea0516d2341740f81a38",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.3 || ^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0"
+ },
+ "bin": [
+ "bin/jsonlint"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Seld\\JsonLint\\": "src/Seld/JsonLint/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ }
+ ],
+ "description": "JSON Linter",
+ "keywords": [
+ "json",
+ "linter",
+ "parser",
+ "validator"
+ ],
+ "time": "2018-01-24T12:46:19+00:00"
+ },
+ {
+ "name": "seld/phar-utils",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Seldaek/phar-utils.git",
+ "reference": "7009b5139491975ef6486545a39f3e6dad5ac30a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/7009b5139491975ef6486545a39f3e6dad5ac30a",
+ "reference": "7009b5139491975ef6486545a39f3e6dad5ac30a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Seld\\PharUtils\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be"
+ }
+ ],
+ "description": "PHAR file format utilities, for when PHP phars you up",
+ "keywords": [
+ "phra"
+ ],
+ "time": "2015-10-13T18:44:15+00:00"
+ },
{
"name": "squizlabs/php_codesniffer",
"version": "3.4.2",
@@ -5229,6 +5562,105 @@
"homepage": "https://symfony.com",
"time": "2019-02-23T15:06:07+00:00"
},
+ {
+ "name": "symfony/filesystem",
+ "version": "v4.2.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/filesystem.git",
+ "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/e16b9e471703b2c60b95f14d31c1239f68f11601",
+ "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1.3",
+ "symfony/polyfill-ctype": "~1.8"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.2-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Filesystem\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony Filesystem Component",
+ "homepage": "https://symfony.com",
+ "time": "2019-02-07T11:40:08+00:00"
+ },
+ {
+ "name": "symfony/finder",
+ "version": "v4.2.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/finder.git",
+ "reference": "e0ff582c4b038567a7c6630f136488b1d793e6a9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/e0ff582c4b038567a7c6630f136488b1d793e6a9",
+ "reference": "e0ff582c4b038567a7c6630f136488b1d793e6a9",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.2-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Finder\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony Finder Component",
+ "homepage": "https://symfony.com",
+ "time": "2019-05-26T20:47:34+00:00"
+ },
{
"name": "symfony/phpunit-bridge",
"version": "v3.4.26",
@@ -5339,7 +5771,8 @@
"minimum-stability": "dev",
"stability-flags": {
"behat/mink": 20,
- "behat/mink-selenium2-driver": 20
+ "behat/mink-selenium2-driver": 20,
+ "composer/composer": 0
},
"prefer-stable": true,
"prefer-lowest": false,
@@ -5357,7 +5790,8 @@
"ext-spl": "*",
"ext-tokenizer": "*",
"ext-xml": "*",
- "php": ">=7.0.8"
+ "php": ">=7.0.8",
+ "composer-plugin-api": "^1.0.0"
},
"platform-dev": []
}
diff --git a/core/composer.json b/core/composer.json
index e5de4f6b2c..0736d6e949 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -56,6 +56,7 @@
"behat/mink": "1.7.x-dev",
"behat/mink-goutte-driver": "^1.2",
"behat/mink-selenium2-driver": "1.3.x-dev",
+ "composer/composer": "^1.8",
"drupal/coder": "^8.3.2",
"jcalderonzumba/gastonjs": "^1.0.2",
"jcalderonzumba/mink-phantomjs-driver": "^0.3.1",
@@ -110,6 +111,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",
@@ -200,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",
diff --git a/core/lib/Drupal/Component/Scaffold/AllowedPackages.php b/core/lib/Drupal/Component/Scaffold/AllowedPackages.php
new file mode 100644
index 0000000000..edfc39bcc3
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/AllowedPackages.php
@@ -0,0 +1,163 @@
+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.
+ $allowed_packages = $this->evaluateNewPackages($allowed_packages);
+ return $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 && $package instanceof PackageInterface && !array_key_exists($name, $allowed_packages)) {
+ $allowed_packages[$name] = $package;
+ $packageOptions = $this->manageOptions->packageOptions($package);
+ $allowed_packages = $this->recursiveGetAllowedPackages($packageOptions->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("Package {$name} has scaffold operations, but it is not allowed in the root-level composer.json file.");
+ }
+ else {
+ $this->io->write("Package {$name} 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.
+ 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, '*');
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/CommandProvider.php b/core/lib/Drupal/Component/Scaffold/CommandProvider.php
new file mode 100644
index 0000000000..9ec63b4a0d
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/CommandProvider.php
@@ -0,0 +1,19 @@
+setName('composer:scaffold')
+ ->setDescription('Update the Composer scaffold files.')
+ ->setHelp(
+ <<composer:scaffold command places the scaffold files in their
+respective locations according to the layout stipulated in the composer.json
+file.
+
+php composer.phar composer:scaffold
+
+It is usually not necessary to call composer:scaffold manually,
+because it is called automatically as needed, e.g. after an install
+or update 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();
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/GenerateAutoloadReferenceFile.php b/core/lib/Drupal/Component/Scaffold/GenerateAutoloadReferenceFile.php
new file mode 100644
index 0000000000..64b240ec42
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/GenerateAutoloadReferenceFile.php
@@ -0,0 +1,97 @@
+fullPath());
+ // Calculate the relative path from the webroot (location of the project
+ // autoload.php) to the vendor directory.
+ $fs = new SymfonyFilesystem();
+ $relative_vendor_path = $fs->makePathRelative($vendor, realpath($location));
+ $fs->dumpFile($autoload_path->fullPath(), static::autoLoadContents($relative_vendor_path));
+ return new ScaffoldResult($autoload_path, TRUE);
+ }
+
+ /**
+ * 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 <<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) {
+ $scaffoldOpFactory = new OperationFactory($this->composer);
+ $scaffoldOps = [];
+ foreach ($package_file_mappings as $dest_rel_path => $metadata) {
+ $scaffoldOps[$dest_rel_path] = $scaffoldOpFactory->create($package, $dest_rel_path, $metadata);
+ }
+ return $scaffoldOps;
+ }
+
+ /**
+ * 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.
+ $allowedPackages = $this->manageAllowedPackages->getAllowedPackages();
+ if (empty($allowedPackages)) {
+ $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($allowedPackages);
+
+ // Analyze the list of file mappings, and determine which take priority.
+ $scaffoldCollection = new OperationCollection($this->io);
+ $locationReplacements = $this->manageOptions->getLocationReplacements();
+
+ // Write the collected scaffold files to the designated location on disk.
+ $scaffoldResults = $scaffoldCollection->process($file_mappings, $locationReplacements, $this->manageOptions->getOptions());
+
+ // 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.
+ $webRoot = $this->manageOptions->getOptions()->getLocation('web-root', FALSE);
+ if (!$webRoot) {
+ throw new \RuntimeException("The extra.composer-scaffold.location.webroot is not set in the project's composer.json.");
+ }
+ $scaffoldResults[] = GenerateAutoloadReferenceFile::generateAutoload($this->rootPackageName(), $webRoot, $this->getVendorPath());
+
+ // Add the managed scaffold files to .gitignore if applicable.
+ $gitIgnoreManager = new ManageGitIgnore(getcwd());
+ $gitIgnoreManager->manageIgnored($scaffoldResults, $this->manageOptions->getOptions());
+
+ // 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() {
+ $vendorDir = $this->composer->getConfig()->get('vendor-dir');
+ $filesystem = new Filesystem();
+ return $filesystem->normalizePath(realpath($vendorDir));
+ }
+
+ /**
+ * 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) {
+ $package_file_mappings = $this->getPackageFileMappings($package);
+ $file_mappings[$package_name] = $package_file_mappings;
+ }
+ 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());
+ }
+ else {
+ if (!$options->hasAllowedPackages()) {
+ $this->io->writeError("The allowed package {$package->getName()} does not provide a file mapping for Composer Scaffold.");
+ }
+ return [];
+ }
+ }
+
+ /**
+ * Gets the root package name.
+ *
+ * @return string
+ * The package name of the root project
+ */
+ protected function rootPackageName() {
+ $root_package = $this->composer->getPackage();
+ return $root_package->getName();
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Interpolator.php b/core/lib/Drupal/Component/Scaffold/Interpolator.php
new file mode 100644
index 0000000000..a80ea26396
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Interpolator.php
@@ -0,0 +1,155 @@
+startToken = $start_token;
+ $this->endToken = $end_token;
+ }
+
+ /**
+ * Sets the data set to use when interpolating.
+ *
+ * @param array $data
+ * Interpolation data to use when interpolating.
+ *
+ * @return $this
+ */
+ public function setData(array $data) {
+ $this->data = $data;
+ return $this;
+ }
+
+ /**
+ * Adds to the data set to use when interpolating.
+ *
+ * @param array $data
+ * Interpolation data to use when interpolating.
+ *
+ * @return $this
+ */
+ public function addData(array $data) {
+ $this->data = array_merge($this->data, $data);
+ return $this;
+ }
+
+ /**
+ * Replaces tokens in a string with values from an associative array.
+ *
+ * Tokens are surrounded by delimiters, e.g. square brackets "[key]". The
+ * characters that surround the key may be defined when the Interpolator is
+ * constructed.
+ *
+ * Example:
+ * If the message is 'Hello, [user.name]', then the value of the user.name
+ * item is fetched from the array, and the token [user.name] is replaced with
+ * the result.
+ *
+ * @param string $message
+ * Message containing tokens to be replaced.
+ * @param array $extra
+ * Data to use for interpolation in addition to whatever was provided to
+ * self::setData().
+ * @param string|bool $default
+ * (optional) The value to substitute for tokens that are not found in the
+ * data. If FALSE, then missing tokens are not replaced. Defaults to an
+ * empty string.
+ *
+ * @return string
+ * The message after replacements have been made.
+ */
+ public function interpolate($message, array $extra = [], $default = '') {
+ $data = $extra + $this->data;
+ $replacements = $this->replacements($message, $data, $default);
+ return strtr($message, $replacements);
+ }
+
+ /**
+ * Finds the tokens that exist in a message and builds a replacement array.
+ *
+ * All of the replacements in the data array are looked up given the token
+ * keys from the provided message. Keys that do not exist in the configuration
+ * are replaced with the default value.
+ *
+ * @param string $message
+ * String with tokens.
+ * @param array $data
+ * Data to use for interpolation.
+ * @param string $default
+ * (optional) The value to substitute for tokens that are not found in the
+ * data. If FALSE, then missing tokens are not replaced. Defaults to an
+ * empty string.
+ *
+ * @return string[]
+ * An array of replacements to make. Keyed by tokens and the replacements
+ * are the values.
+ */
+ protected function replacements($message, array $data, $default = '') {
+ $tokens = $this->findTokens($message);
+ $replacements = [];
+ foreach ($tokens as $sourceText => $key) {
+ $replacementText = array_key_exists($key, $data) ? $data[$key] : $default;
+ if ($replacementText !== FALSE) {
+ $replacements[$sourceText] = $replacementText;
+ }
+ }
+ return $replacements;
+ }
+
+ /**
+ * Finds all of the tokens in the provided message.
+ *
+ * @param string $message
+ * String with tokens.
+ *
+ * @return string[]
+ * map of token to key, e.g. {{key}} => key
+ */
+ protected function findTokens($message) {
+ $regEx = '#' . $this->startToken . '([a-zA-Z0-9._-]+)' . $this->endToken . '#';
+ if (!preg_match_all($regEx, $message, $matches, PREG_SET_ORDER)) {
+ return [];
+ }
+ $tokens = [];
+ foreach ($matches as $matchSet) {
+ list($sourceText, $key) = $matchSet;
+ $tokens[$sourceText] = $key;
+ }
+ return $tokens;
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/LICENSE.txt b/core/lib/Drupal/Component/Scaffold/LICENSE.txt
new file mode 100644
index 0000000000..94fb84639c
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/LICENSE.txt
@@ -0,0 +1,339 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, write to the Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ , 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/core/lib/Drupal/Component/Scaffold/ManageGitIgnore.php b/core/lib/Drupal/Component/Scaffold/ManageGitIgnore.php
new file mode 100644
index 0000000000..9985f3ca9f
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ManageGitIgnore.php
@@ -0,0 +1,171 @@
+dir = $dir;
+ }
+
+ /**
+ * Manages gitignore files.
+ *
+ * @param \Drupal\Component\Scaffold\Operations\ScaffoldResult[] $files
+ * A list of scaffold results, each of which holds a path and whether
+ * or not that file is managed.
+ * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+ * Configuration options from the composer.json extras section.
+ */
+ public function manageIgnored(array $files, ScaffoldOptions $options) {
+ if (!$this->managementOfGitIgnoreEnabled($options)) {
+ return;
+ }
+
+ // Accumulate entries to add to .gitignore, sorted into buckets based on the
+ // location of the .gitignore file the entry should be added to.
+ $addToGitIgnore = [];
+ foreach ($files as $scaffoldResult) {
+ $isIgnored = $this->checkIgnore($scaffoldResult->destination()->fullPath());
+ if (!$isIgnored) {
+ $isTracked = $this->checkTracked($scaffoldResult->destination()->fullPath());
+ if (!$isTracked && $scaffoldResult->isManaged()) {
+ $path = $scaffoldResult->destination()->fullPath();
+ $dir = realpath(dirname($path));
+ $name = basename($path);
+ $addToGitIgnore[$dir][] = $name;
+ }
+ }
+ }
+ // Write out the .gitignore files one at a time.
+ foreach ($addToGitIgnore as $dir => $entries) {
+ $this->addToGitIgnore($dir, $entries);
+ }
+ }
+
+ /**
+ * Determines whether the specified scaffold file is already ignored.
+ *
+ * @param string $path
+ * Path to scaffold file to check.
+ *
+ * @return bool
+ * Whether the specified file is already ignored or not (TRUE if ignored).
+ */
+ protected function checkIgnore($path) {
+ $process = new Process('git check-ignore ' . $path, $this->dir);
+ $process->run();
+ $isIgnored = $process->getExitCode() == 0;
+ return $isIgnored;
+ }
+
+ /**
+ * Determines whether the specified scaffold file is tracked by git.
+ *
+ * @param string $path
+ * Path to scaffold file to check.
+ *
+ * @return bool
+ * Whether the specified file is already tracked or not (TRUE if tracked).
+ */
+ protected function checkTracked($path) {
+ $process = new Process('git ls-files --error-unmatch ' . $path, $this->dir);
+ $process->run();
+ $isTracked = $process->getExitCode() == 0;
+ return $isTracked;
+ }
+
+ /**
+ * Checks to see if the project root dir is in a git repository.
+ *
+ * @return bool
+ * True if this is a repository.
+ */
+ protected function isRepository() {
+ $process = new Process('git rev-parse --show-toplevel', $this->dir);
+ $process->run();
+ $isRepository = $process->getExitCode() == 0;
+ return $isRepository;
+ }
+
+ /**
+ * Checks to see if the vendor directory is git ignored.
+ *
+ * @return bool
+ * True if 'vendor' is committed, or false if it is ignored.
+ */
+ protected function vendorCommitted() {
+ return $this->checkTracked('vendor');
+ }
+
+ /**
+ * Determines whether we should manage gitignore files.
+ *
+ * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+ * Configuration options from the composer.json extras section.
+ *
+ * @return bool
+ * Whether or not gitignore files should be managed.
+ */
+ protected function managementOfGitIgnoreEnabled(ScaffoldOptions $options) {
+ // If the composer.json stipulates whether gitignore is managed or not, then
+ // follow its recommendation.
+ if ($options->hasGitIgnore()) {
+ return $options->gitIgnore();
+ }
+
+ // Do not manage .gitignore if there is no repository here.
+ if (!$this->isRepository()) {
+ return FALSE;
+ }
+
+ // If the composer.json did not specify whether or not .gitignore files
+ // should be managed, then manage them if the vendor directory is not
+ // committed.
+ return !$this->vendorCommitted();
+ }
+
+ /**
+ * Adds a set of entries to the specified .gitignore file.
+ *
+ * @param string $dir
+ * Path to directory where gitignore should be written.
+ * @param string[] $entries
+ * Entries to write to .gitignore file.
+ */
+ protected function addToGitIgnore($dir, array $entries) {
+ sort($entries);
+ $gitIgnorePath = $dir . '/.gitignore';
+ $contents = '';
+
+ // Appending to existing .gitignore files.
+ if (file_exists($gitIgnorePath)) {
+ $contents = file_get_contents($gitIgnorePath);
+ if (!empty($contents) && substr($contents, -1) != "\n") {
+ $contents .= "\n";
+ }
+ }
+
+ $contents .= implode("\n", $entries);
+ file_put_contents($gitIgnorePath, $contents);
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/ManageOptions.php b/core/lib/Drupal/Component/Scaffold/ManageOptions.php
new file mode 100644
index 0000000000..be69cfb1a2
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ManageOptions.php
@@ -0,0 +1,90 @@
+composer = $composer;
+ }
+
+ /**
+ * Gets the root-level scaffold options for this project.
+ *
+ * @return \Drupal\Component\Scaffold\ScaffoldOptions
+ * The scaffold options object.
+ */
+ public function getOptions() {
+ return $this->packageOptions($this->composer->getPackage());
+ }
+
+ /**
+ * Gets the scaffold options for the stipulated project.
+ *
+ * @param \Composer\Package\PackageInterface $package
+ * The package to fetch the scaffold options from.
+ *
+ * @return \Drupal\Component\Scaffold\ScaffoldOptions
+ * The scaffold options object.
+ */
+ public function packageOptions(PackageInterface $package) {
+ return ScaffoldOptions::create($package->getExtra());
+ }
+
+ /**
+ * Creates an interpolator for the 'locations' element.
+ *
+ * The interpolator returned will replace a path string with the tokens
+ * defined in the 'locations' element.
+ *
+ * Note that only the root package may define locations.
+ *
+ * @return \Drupal\Component\Scaffold\Interpolator
+ * Interpolator that will do replacements in a string using tokens in
+ * 'locations' element.
+ */
+ public function getLocationReplacements() {
+ return (new Interpolator())->setData($this->ensureLocations());
+ }
+
+ /**
+ * Ensures that all of the locations defined in the scaffold filed exist.
+ *
+ * Create them on the filesystem if they do not.
+ */
+ protected function ensureLocations() {
+ $fs = new Filesystem();
+ $locations = $this->getOptions()->locations() + ['web_root' => './'];
+ $locations = array_map(function ($location) use ($fs) {
+ $fs->ensureDirectoryExists($location);
+ $location = realpath($location);
+ return $location;
+ }, $locations);
+ return $locations;
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/AppendOp.php b/core/lib/Drupal/Component/Scaffold/Operations/AppendOp.php
new file mode 100644
index 0000000000..d8ca757a4d
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/AppendOp.php
@@ -0,0 +1,79 @@
+prepend = $prepend_path;
+ $this->append = $append_path;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
+ $destination_path = $destination->fullPath();
+ if (!file_exists($destination_path)) {
+ throw new \RuntimeException($destination->getInterpolator()->interpolate("Cannot append/prepend because no prior package provided a scaffold file at that [dest-rel-path]."));
+ }
+ $interpolator = $destination->getInterpolator();
+
+ // Fetch the prepend contents, if provided.
+ $prependContents = '';
+ if (!empty($this->prepend)) {
+ $this->prepend->addInterpolationData($interpolator, 'prepend');
+ $prependContents = file_get_contents($this->prepend->fullPath()) . "\n";
+ $io->write($interpolator->interpolate(" - Prepend to [dest-rel-path] from [prepend-rel-path]"));
+ }
+ // Fetch the append contents, if provided.
+ $appendContents = '';
+ if (!empty($this->append)) {
+ $this->append->addInterpolationData($interpolator, 'append');
+ $appendContents = "\n" . file_get_contents($this->append->fullPath());
+ $io->write($interpolator->interpolate(" - Append to [dest-rel-path] from [append-rel-path]"));
+ }
+ if (!empty(trim($prependContents)) || !empty(trim($appendContents))) {
+ // Assume that none of these files is very large, so load them all into
+ // memory for now. Considering uses streams to scaffold large files.
+ $originalContents = file_get_contents($destination_path);
+ // Write the appended and prepended contents back to the file.
+ $alteredContents = $prependContents . $originalContents . $appendContents;
+ file_put_contents($destination_path, $alteredContents);
+ }
+ else {
+ $io->write($interpolator->interpolate(" - Keep [dest-rel-path] unchanged: no content to prepend / append was provided."));
+ }
+ return new ScaffoldResult($destination, TRUE);
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/ConjoinableInterface.php b/core/lib/Drupal/Component/Scaffold/Operations/ConjoinableInterface.php
new file mode 100644
index 0000000000..4f494173fa
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/ConjoinableInterface.php
@@ -0,0 +1,15 @@
+firstOperation = $first_operation;
+ $this->secondOperation = $second_operation;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
+ $destination_path = $destination->fullPath();
+ // First, scaffold the original file. Disable symlinking, because we
+ // need a copy of the file if we're going to append / prepend to it.
+ @unlink($destination_path);
+ $this->firstOperation->process($destination, $io, $options->overrideSymlink(FALSE));
+ return $this->secondOperation->process($destination, $io, $options);
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/OperationCollection.php b/core/lib/Drupal/Component/Scaffold/Operations/OperationCollection.php
new file mode 100644
index 0000000000..ce2c00ddfd
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/OperationCollection.php
@@ -0,0 +1,156 @@
+io = $io;
+ }
+
+ /**
+ * Finds the package name that provides the scaffold file.
+ *
+ * Given the list of all scaffold file info objects, return the package that
+ * provides the scaffold file info for the scaffold file that will be placed
+ * at the destination that this scaffold file would be placed at. Note that
+ * this will be the same as $scaffold_file->packageName() unless this scaffold
+ * file has been overridden or removed by some other package.
+ *
+ * @param \Drupal\Component\Scaffold\ScaffoldFileInfo[] $list_of_scaffold_files
+ * Associative array containing destination => operation mappings.
+ * @param \Drupal\Component\Scaffold\ScaffoldFileInfo $scaffold_file
+ * The scaffold file to use to find a providing package name.
+ *
+ * @return string
+ * The name of the package that provided the scaffold file information.
+ */
+ protected function findProvidingPackage(array $list_of_scaffold_files, ScaffoldFileInfo $scaffold_file) {
+ // The scaffold file should always be in our list, but we will check
+ // just to be sure that it really is.
+ $dest_rel_path = $scaffold_file->destination()->relativePath();
+ if (!array_key_exists($dest_rel_path, $list_of_scaffold_files)) {
+ throw new \RuntimeException("Scaffold file not found in list of all scaffold files.");
+ }
+ return $list_of_scaffold_files[$dest_rel_path]->packageName();
+ }
+
+ /**
+ * Process all of the scaffold files listed in the provided file mappings.
+ *
+ * @param array $file_mappings
+ * An multidimensional array of file mappings, as returned by
+ * self::getFileMappingsFromPackages().
+ * @param \Drupal\Component\Scaffold\Interpolator $location_replacements
+ * An object with the location mappings (e.g. [web-root]).
+ * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+ * Configuration options from the top-level composer.json file.
+ *
+ * @return \Drupal\Component\Scaffold\Operations\ScaffoldResult[]
+ * Associative array keyed by destination path and values as the scaffold
+ * result for each scaffolded file.
+ */
+ public function process(array $file_mappings, Interpolator $location_replacements, ScaffoldOptions $options) {
+ list($list_of_scaffold_files, $resolved_file_mappings) = $this->collateScaffoldFiles($file_mappings, $location_replacements);
+ return $this->processScaffoldFiles($list_of_scaffold_files, $resolved_file_mappings, $options);
+ }
+
+ /**
+ * Organizes provided file mappings by destination and package.
+ *
+ * @param array $file_mappings
+ * An multidimensional array of file mappings, as returned by
+ * self::getFileMappingsFromPackages().
+ * @param \Drupal\Component\Scaffold\Interpolator $location_replacements
+ * An object with the location mappings (e.g. [web-root]).
+ *
+ * @return array
+ * A list containing two lists:
+ * - Associative array containing destination => operation mappings.
+ * - Associative array containing package name => file mappings.
+ */
+ protected function collateScaffoldFiles(array $file_mappings, Interpolator $location_replacements) {
+ $resolved_file_mappings = [];
+ /** @var \Drupal\Component\Scaffold\ScaffoldFileInfo[] $list_of_scaffold_files */
+ $list_of_scaffold_files = [];
+ foreach ($file_mappings as $package_name => $package_file_mappings) {
+ foreach ($package_file_mappings as $destination_rel_path => $op) {
+ $destination = ScaffoldFilePath::destinationPath($package_name, $destination_rel_path, $location_replacements);
+ // If there was already a scaffolding operation happening at this path,
+ // and the new operation is Conjoinable, then use a ConjunctionOp to
+ // join together both operations. This will cause both operations to
+ // run, one after the other. At the moment, only AppendOp is conjoinable;
+ // all other operations simply replace anything at the same path.
+ if (isset($list_of_scaffold_files[$destination_rel_path]) && $op instanceof ConjoinableInterface) {
+ $op = new ConjunctionOp($list_of_scaffold_files[$destination_rel_path]->op(), $op);
+ }
+
+ $scaffold_file = new ScaffoldFileInfo($destination, $op);
+ $list_of_scaffold_files[$destination_rel_path] = $scaffold_file;
+ $resolved_file_mappings[$package_name][$destination_rel_path] = $scaffold_file;
+ }
+ }
+ return [$list_of_scaffold_files, $resolved_file_mappings];
+ }
+
+ /**
+ * Scaffolds the files in our scaffold collection, package-by-package.
+ *
+ * @param array $list_of_scaffold_files
+ * Associative array containing destination => operation mappings.
+ * @param array $resolved_file_mappings
+ * Associative array containing package name => file mappings.
+ * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+ * Configuration options from the top-level composer.json file.
+ *
+ * @return \Drupal\Component\Scaffold\Operations\ScaffoldResult[]
+ * Associative array keyed by destination path and values as the scaffold
+ * result for each scaffolded file.
+ */
+ protected function processScaffoldFiles(array $list_of_scaffold_files, array $resolved_file_mappings, ScaffoldOptions $options) {
+ $result = [];
+ // We could simply scaffold all of the files from $list_of_scaffold_files,
+ // which contain only the list of files to be processed. We iterate over
+ // $resolved_file_mappings instead so that we can print out all of the
+ // scaffold files grouped by the package that provided them, including
+ // those not being scaffolded (because they were overridden or removed
+ // by some later package).
+ foreach ($resolved_file_mappings as $package_name => $package_scaffold_files) {
+ $this->io->write("Scaffolding files for {$package_name}:");
+ foreach ($package_scaffold_files as $dest_rel_path => $scaffold_file) {
+ $overriding_package = $this->findProvidingPackage($list_of_scaffold_files, $scaffold_file);
+ if ($scaffold_file->overridden($overriding_package)) {
+ $this->io->write($scaffold_file->interpolate(" - Skip [dest-rel-path]: overridden in {$overriding_package}"));
+ }
+ else {
+ $result[$dest_rel_path] = $scaffold_file->process($this->io, $options);
+ }
+ }
+ }
+ return $result;
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/OperationFactory.php b/core/lib/Drupal/Component/Scaffold/Operations/OperationFactory.php
new file mode 100644
index 0000000000..7ede3241d6
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/OperationFactory.php
@@ -0,0 +1,185 @@
+composer = $composer;
+ }
+
+ /**
+ * Creates a scaffolding operation object as determined by the metadata.
+ *
+ * @param \Composer\Package\PackageInterface $package
+ * The package that relative paths will be relative from.
+ * @param string $destination
+ * The destination path for the scaffold file. Used only for error messages.
+ * @param mixed $metadata
+ * The metadata for this operation object, which varies by operation type.
+ *
+ * @return \Drupal\Component\Scaffold\Operations\OperationInterface
+ * The scaffolding operation object (skip, replace, etc.)
+ *
+ * @throws \RuntimeException
+ * Exception thrown when $metadata can not be used to determine a scaffold
+ * operation.
+ */
+ public function create(PackageInterface $package, $destination, $metadata) {
+ $metadata = $this->normalizeScaffoldMetadata($destination, $metadata);
+ switch ($metadata['mode']) {
+ case 'skip':
+ return new SkipOp();
+
+ case 'replace':
+ return $this->createReplaceOp($package, $destination, $metadata);
+
+ case 'append':
+ return $this->createAppendOp($package, $destination, $metadata);
+ }
+ throw new \RuntimeException("Unknown scaffold operation mode {$metadata['mode']}.");
+ }
+
+ /**
+ * Creates a 'replace' scaffold op.
+ *
+ * Replace ops may copy or symlink, depending on settings.
+ *
+ * @param \Composer\Package\PackageInterface $package
+ * The package that relative paths will be relative from.
+ * @param string $destination
+ * The destination path for the scaffold file. Used only for error messages.
+ * @param array $metadata
+ * The metadata for this operation object, i.e. the relative 'path'.
+ *
+ * @return \Drupal\Component\Scaffold\Operations\OperationInterface
+ * A scaffold replace operation object.
+ */
+ protected function createReplaceOp(PackageInterface $package, $destination, array $metadata) {
+ // If this op does not provide an 'overwrite' value, default it to true.
+ $metadata += ['overwrite' => TRUE];
+ if (!isset($metadata['path'])) {
+ throw new \RuntimeException("'path' component required for 'replace' operations.");
+ }
+ $package_name = $package->getName();
+ $package_path = $this->getPackagePath($package);
+ $source = ScaffoldFilePath::sourcePath($package_name, $package_path, $destination, $metadata['path']);
+ $op = new ReplaceOp($source, $metadata['overwrite']);
+ return $op;
+ }
+
+ /**
+ * Creates an 'append' (or 'prepend') scaffold op.
+ *
+ * @param \Composer\Package\PackageInterface $package
+ * The package that relative paths will be relative from.
+ * @param string $destination
+ * The destination path for the scaffold file. Used only for error messages.
+ * @param array $metadata
+ * The metadata for this operation object, i.e. the relative 'path'.
+ *
+ * @return \Drupal\Component\Scaffold\Operations\OperationInterface
+ * A scaffold replace operation object.
+ */
+ protected function createAppendOp(PackageInterface $package, $destination, array $metadata) {
+ $package_name = $package->getName();
+ $package_path = $this->getPackagePath($package);
+ $prepend_source_file = NULL;
+ $append_source_file = NULL;
+ if (isset($metadata['prepend'])) {
+ $prepend_source_file = ScaffoldFilePath::sourcePath($package_name, $package_path, $destination, $metadata['prepend']);
+ }
+ if (isset($metadata['append'])) {
+ $append_source_file = ScaffoldFilePath::sourcePath($package_name, $package_path, $destination, $metadata['append']);
+ }
+ $op = new AppendOp($prepend_source_file, $append_source_file);
+ return $op;
+ }
+
+ /**
+ * Gets the file path of a package.
+ *
+ * Note that if we call getInstallPath on the root package, we get the
+ * wrong answer (the installation manager thinks our package is in
+ * vendor). We therefore add special checking for this case.
+ *
+ * @param \Composer\Package\PackageInterface $package
+ * The package.
+ *
+ * @return string
+ * The file path.
+ */
+ protected function getPackagePath(PackageInterface $package) {
+ if ($package->getName() == $this->composer->getPackage()->getName()) {
+ // This will respect the --working-dir option if Composer is invoked with
+ // it. There is no API or method to determine the filesystem path of
+ // a package's composer.json file.
+ return getcwd();
+ }
+ else {
+ return $this->composer->getInstallationManager()->getInstallPath($package);
+ }
+ }
+
+ /**
+ * Normalizes metadata by converting literal values into arrays.
+ *
+ * Conversions performed include:
+ * - Boolean 'false' means "skip".
+ * - A string means "replace", with the string value becoming the path.
+ *
+ * @param string $destination
+ * The destination path for the scaffold file.
+ * @param mixed $value
+ * The metadata for this operation object, which varies by operation type.
+ *
+ * @return array
+ * Normalized scaffold metadata.
+ */
+ protected function normalizeScaffoldMetadata($destination, $value) {
+ if (is_bool($value)) {
+ if (!$value) {
+ return ['mode' => 'skip'];
+ }
+ throw new \RuntimeException("File mapping {$destination} cannot be given the value 'true'.");
+ }
+ if (empty($value)) {
+ throw new \RuntimeException("File mapping {$destination} cannot be empty.");
+ }
+ if (is_string($value)) {
+ $value = ['path' => $value];
+ }
+ // If there is no 'mode', but there is an 'append' or a 'prepend' path,
+ // then the mode is 'append' (append + prepend).
+ if (!isset($value['mode']) && (isset($value['append']) || isset($value['prepend']))) {
+ $value['mode'] = 'append';
+ }
+ // If there is no 'mode', then the default is 'replace'.
+ if (!isset($value['mode'])) {
+ $value['mode'] = 'replace';
+ }
+ return $value;
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/OperationInterface.php b/core/lib/Drupal/Component/Scaffold/Operations/OperationInterface.php
new file mode 100644
index 0000000000..8fa0cee2be
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/OperationInterface.php
@@ -0,0 +1,29 @@
+source = $sourcePath;
+ $this->overwrite = $overwrite;
+ }
+
+ /**
+ * Copy or Symlink the specified scaffold file.
+ *
+ * {@inheritdoc}
+ */
+ public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
+ $fs = new Filesystem();
+ $destination_path = $destination->fullPath();
+ // Do nothing if overwrite is 'false' and a file already exists at the
+ // destination.
+ if ($this->overwrite === FALSE && file_exists($destination_path)) {
+ $interpolator = $destination->getInterpolator();
+ $io->write($interpolator->interpolate(" - Skip [dest-rel-path] because it already exists and overwrite is false."));
+ return new ScaffoldResult($destination, FALSE);
+ }
+
+ // Get rid of the destination if it exists, and make sure that
+ // the directory where it's going to be placed exists.
+ @unlink($destination_path);
+ $fs->ensureDirectoryExists(dirname($destination_path));
+ if ($options->symlink() == TRUE) {
+ return $this->symlinkScaffold($destination, $io);
+ }
+ return $this->copyScaffold($destination, $io);
+ }
+
+ /**
+ * Copies the scaffold file.
+ *
+ * @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
+ * Scaffold file to process.
+ * @param \Composer\IO\IOInterface $io
+ * IOInterface to writing to.
+ *
+ * @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
+ * The scaffold result.
+ */
+ protected function copyScaffold(ScaffoldFilePath $destination, IOInterface $io) {
+ $interpolator = $destination->getInterpolator();
+ $this->source->addInterpolationData($interpolator);
+ $success = copy($this->source->fullPath(), $destination->fullPath());
+ if (!$success) {
+ throw new \RuntimeException($interpolator->interpolate("Could not copy source file [src-rel-path] to [dest-rel-path]!"));
+ }
+ $io->write($interpolator->interpolate(" - Copy [dest-rel-path] from [src-rel-path]"));
+ return new ScaffoldResult($destination, $this->overwrite);
+ }
+
+ /**
+ * Symlinks the scaffold file.
+ *
+ * @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
+ * Scaffold file to process.
+ * @param \Composer\IO\IOInterface $io
+ * IOInterface to writing to.
+ *
+ * @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
+ * The scaffold result.
+ */
+ protected function symlinkScaffold(ScaffoldFilePath $destination, IOInterface $io) {
+ $interpolator = $destination->getInterpolator();
+ try {
+ $fs = new Filesystem();
+ $fs->relativeSymlink($this->source->fullPath(), $destination->fullPath());
+ }
+ catch (\Exception $e) {
+ throw new \RuntimeException($interpolator->interpolate("Could not symlink source file [src-rel-path] to [dest-rel-path]!"), [], $e);
+ }
+ $io->write($interpolator->interpolate(" - Link [dest-rel-path] from [src-rel-path]"));
+ return new ScaffoldResult($destination, $this->overwrite);
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/ScaffoldResult.php b/core/lib/Drupal/Component/Scaffold/Operations/ScaffoldResult.php
new file mode 100644
index 0000000000..b542b1e1a3
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/ScaffoldResult.php
@@ -0,0 +1,59 @@
+destination = $destination;
+ $this->managed = $isManaged;
+ }
+
+ /**
+ * Determines whether this scaffold file is managed.
+ *
+ * @return bool
+ * TRUE if this scaffold file is managed, FALSE if not.
+ */
+ public function isManaged() {
+ return $this->managed;
+ }
+
+ /**
+ * Gets the destination scaffold file that this result refers to.
+ *
+ * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+ * The destination path for the scaffold result.
+ */
+ public function destination() {
+ return $this->destination;
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Operations/SkipOp.php b/core/lib/Drupal/Component/Scaffold/Operations/SkipOp.php
new file mode 100644
index 0000000000..444dafac7e
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Operations/SkipOp.php
@@ -0,0 +1,23 @@
+getInterpolator();
+ $io->write($interpolator->interpolate(" - Skip [dest-rel-path]: disabled"));
+ return new ScaffoldResult($destination, FALSE);
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/Plugin.php b/core/lib/Drupal/Component/Scaffold/Plugin.php
new file mode 100644
index 0000000000..cee41d8417
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/Plugin.php
@@ -0,0 +1,90 @@
+handler = new Handler($composer, $io);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCapabilities() {
+ return ['Composer\Plugin\Capability\CommandProvider' => ScaffoldCommandProvider::class];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getSubscribedEvents() {
+ return [
+ ScriptEvents::POST_UPDATE_CMD => 'postCmd',
+ PackageEvents::POST_PACKAGE_INSTALL => 'postPackage',
+ PluginEvents::COMMAND => 'onCommand',
+ ];
+ }
+
+ /**
+ * Post command event callback.
+ *
+ * @param \Composer\Script\Event $event
+ * The Composer event.
+ */
+ public function postCmd(Event $event) {
+ $this->handler->scaffold();
+ }
+
+ /**
+ * Post package event behaviour.
+ *
+ * @param \Composer\Installer\PackageEvent $event
+ * Composer package event sent on install/update/remove.
+ */
+ public function postPackage(PackageEvent $event) {
+ $this->handler->onPostPackageEvent($event);
+ }
+
+ /**
+ * Pre command event callback.
+ *
+ * @param \Composer\Plugin\CommandEvent $event
+ * The Composer command event.
+ */
+ public function onCommand(CommandEvent $event) {
+ if ($event->getCommandName() == 'require') {
+ $this->handler->beforeRequire($event);
+ }
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/PostPackageEventListenerInterface.php b/core/lib/Drupal/Component/Scaffold/PostPackageEventListenerInterface.php
new file mode 100644
index 0000000000..2285146f27
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/PostPackageEventListenerInterface.php
@@ -0,0 +1,22 @@
+destination = $destination;
+ $this->op = $op;
+ }
+
+ /**
+ * Gets the Scaffold operation.
+ *
+ * @return \Drupal\Component\Scaffold\Operations\OperationInterface
+ * Operations object that handles scaffolding (copy, make symlink, etc).
+ */
+ public function op() {
+ return $this->op;
+ }
+
+ /**
+ * Gets the package name.
+ *
+ * @return string
+ * The name of the package this scaffold file info was collected from.
+ */
+ public function packageName() {
+ return $this->destination->packageName();
+ }
+
+ /**
+ * Gets the destination.
+ *
+ * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+ * The scaffold path to the destination file.
+ */
+ public function destination() {
+ return $this->destination;
+ }
+
+ /**
+ * Determines if this scaffold file has been overridden by another package.
+ *
+ * @param string $providing_package
+ * The name of the package that provides the scaffold file at this location,
+ * as returned by self::findProvidingPackage()
+ *
+ * @return bool
+ * Whether this scaffold file if overridden or removed.
+ */
+ public function overridden($providing_package) {
+ return $this->packageName() !== $providing_package;
+ }
+
+ /**
+ * Replaces placeholders in a message.
+ *
+ * @param string $message
+ * Message with placeholders to fill in.
+ * @param array $extra
+ * Additional data to merge with the interpolator.
+ * @param mixed $default
+ * Default value to use for missing placeholders, or FALSE to keep them.
+ *
+ * @return string
+ * Interpolated string with placeholders replaced.
+ */
+ public function interpolate($message, array $extra = [], $default = FALSE) {
+ $interpolator = $this->getInterpolator();
+ return $interpolator->interpolate($message, $extra, $default);
+ }
+
+ /**
+ * Moves a single scaffold file from source to destination.
+ *
+ * @param \Composer\IO\IOInterface $io
+ * The scaffold file to be processed.
+ * @param \Drupal\Component\Scaffold\ScaffoldOptions $options
+ * Assorted operational options, e.g. whether the destination should be a
+ * symlink.
+ *
+ * @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
+ * The scaffold result.
+ */
+ public function process(IOInterface $io, ScaffoldOptions $options) {
+ return $this->op()->process($this->destination, $io, $options);
+ }
+
+ /**
+ * Interpolates a string using the data from this scaffold file info.
+ *
+ * @return \Drupal\Component\Scaffold\Interpolator
+ * An interpolator for making string replacements.
+ */
+ protected function getInterpolator() {
+ return $this->destination->getInterpolator();
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/ScaffoldFilePath.php b/core/lib/Drupal/Component/Scaffold/ScaffoldFilePath.php
new file mode 100644
index 0000000000..8e842a68e1
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ScaffoldFilePath.php
@@ -0,0 +1,190 @@
+type = $path_type;
+ $this->packageName = $package_name;
+ $this->relativePath = $rel_path;
+ $this->fullPath = $full_path;
+ }
+
+ /**
+ * Gets the name of the package this source file was pulled from.
+ *
+ * @return string
+ * Name of package.
+ */
+ public function packageName() {
+ return $this->packageName;
+ }
+
+ /**
+ * Gets the relative path to the source file (best to use in messages).
+ *
+ * @return string
+ * Relative path to file.
+ */
+ public function relativePath() {
+ return $this->relativePath;
+ }
+
+ /**
+ * Gets the full path to the source file.
+ *
+ * @return string
+ * Full path to file.
+ */
+ public function fullPath() {
+ return $this->fullPath;
+ }
+
+ /**
+ * Converts the relative source path into an absolute path.
+ *
+ * The path returned will be relative to the package installation location.
+ *
+ * @param string $package_name
+ * The name of the package containing the source file. Only used for error
+ * messages.
+ * @param string $package_path
+ * The installation path of the package containing the source file.
+ * @param string $destination
+ * Destination location provided as a relative path. Only used for error
+ * messages.
+ * @param string $source
+ * Source location provided as a relative path.
+ *
+ * @return self
+ * Object wrapping the relative and absolute path to the source file.
+ */
+ public static function sourcePath($package_name, $package_path, $destination, $source) {
+ // Complain if there is no source path.
+ if (empty($source)) {
+ throw new \RuntimeException("No scaffold file path given for {$destination} in package {$package_name}.");
+ }
+ // Calculate the full path to the source scaffold file.
+ $source_full_path = $package_path . '/' . $source;
+ if (!file_exists($source_full_path)) {
+ throw new \RuntimeException("Scaffold file {$source} not found in package {$package_name}.");
+ }
+ if (is_dir($source_full_path)) {
+ throw new \RuntimeException("Scaffold file {$source} in package {$package_name} is a directory; only files may be scaffolded.");
+ }
+ return new self('src', $package_name, $source, $source_full_path);
+ }
+
+ /**
+ * Converts the relative destination path into an absolute path.
+ *
+ * Any placeholders in the destination path, e.g. '[web-root]', will be
+ * replaced using the provided location replacements interpolator.
+ *
+ * @param string $package_name
+ * The name of the package defining the destination path.
+ * @param string $destination
+ * The relative path to the destination file being scaffolded.
+ * @param \Drupal\Component\Scaffold\Interpolator $location_replacements
+ * Interpolator that includes the [web-root] and any other available
+ * placeholder replacements.
+ *
+ * @return self
+ * Object wrapping the relative and absolute path to the destination file.
+ */
+ public static function destinationPath($package_name, $destination, Interpolator $location_replacements) {
+ $dest_full_path = $location_replacements->interpolate($destination);
+ return new self('dest', $package_name, $destination, $dest_full_path);
+ }
+
+ /**
+ * Adds data about the relative and full path to the provided interpolator.
+ *
+ * @param \Drupal\Component\Scaffold\Interpolator $interpolator
+ * Interpolator to add data to.
+ * @param string $name_prefix
+ * (optional) Prefix to add before -rel-path and -full-path item names.
+ * Defaults to path type provided when constructing this object.
+ */
+ public function addInterpolationData(Interpolator $interpolator, $name_prefix = '') {
+ if (empty($name_prefix)) {
+ $name_prefix = $this->type;
+ }
+ $data = [
+ 'package-name' => $this->packageName(),
+ "{$name_prefix}-rel-path" => $this->relativePath(),
+ "{$name_prefix}-full-path" => $this->fullPath(),
+ ];
+ $interpolator->addData($data);
+ }
+
+ /**
+ * Interpolate a string using the data from this scaffold file info.
+ *
+ * @param string $name_prefix
+ * (optional) Prefix to add before -rel-path and -full-path item names.
+ * Defaults to path type provided when constructing this object.
+ *
+ * @return \Drupal\Component\Scaffold\Interpolator
+ * An interpolator for making string replacements.
+ */
+ public function getInterpolator($name_prefix = '') {
+ $interpolator = new Interpolator();
+ $this->addInterpolationData($interpolator, $name_prefix);
+ return $interpolator;
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/ScaffoldOptions.php b/core/lib/Drupal/Component/Scaffold/ScaffoldOptions.php
new file mode 100644
index 0000000000..ddc86659a0
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/ScaffoldOptions.php
@@ -0,0 +1,197 @@
+options = $options + [
+ "allowed-packages" => [],
+ "locations" => [],
+ "symlink" => FALSE,
+ "file-mapping" => [],
+ ];
+ }
+
+ /**
+ * Determines if the provided 'extras' section has scaffold options.
+ *
+ * @param array $extras
+ * The contents of the 'extras' section.
+ *
+ * @return bool
+ * True if scaffold options have been declared
+ */
+ public static function hasOptions(array $extras) {
+ return array_key_exists('composer-scaffold', $extras);
+ }
+
+ /**
+ * Creates a scaffold options object.
+ *
+ * @param array $extras
+ * The contents of the 'extras' section.
+ *
+ * @return self
+ * The scaffold options object representing the provided scaffold options
+ */
+ public static function create(array $extras) {
+ $options = static::hasOptions($extras) ? $extras['composer-scaffold'] : [];
+ return new self($options);
+ }
+
+ /**
+ * Creates a new scaffold options object with some values overridden.
+ *
+ * @param array $options
+ * Override values.
+ *
+ * @return self
+ * The scaffold options object representing the provided scaffold options
+ */
+ protected function override(array $options) {
+ return new self($options + $this->options);
+ }
+
+ /**
+ * Creates a new scaffold options object with an overridden 'symlink' value.
+ *
+ * @param bool $symlink
+ * Whether symlinking should be enabled or not.
+ *
+ * @return self
+ * The scaffold options object representing the provided scaffold options
+ */
+ public function overrideSymlink($symlink) {
+ return $this->override(['symlink' => $symlink]);
+ }
+
+ /**
+ * Determines whether any allowed packages were defined.
+ *
+ * @return bool
+ * Whether there are allowed packages
+ */
+ public function hasAllowedPackages() {
+ return !empty($this->allowedPackages());
+ }
+
+ /**
+ * Gets allowed packages from these options.
+ *
+ * @return array
+ * The list of allowed packages
+ */
+ public function allowedPackages() {
+ return $this->options['allowed-packages'];
+ }
+
+ /**
+ * Gets the location mapping table, e.g. 'webroot' => './'.
+ *
+ * @return array
+ * A map of name : location values
+ */
+ public function locations() {
+ return $this->options['locations'];
+ }
+
+ /**
+ * Determines whether a given named location is defined.
+ *
+ * @param string $name
+ * The location name to search for.
+ *
+ * @return bool
+ * True if the specified named location exist.
+ */
+ protected function hasLocation($name) {
+ return array_key_exists($name, $this->locations());
+ }
+
+ /**
+ * Gets a specific named location.
+ *
+ * @param string $name
+ * The name of the location to fetch.
+ * @param string $default
+ * The value to return if the requested location is not defined.
+ *
+ * @return string
+ * The value of the provided named location
+ */
+ public function getLocation($name, $default = '') {
+ return $this->hasLocation($name) ? $this->locations()[$name] : $default;
+ }
+
+ /**
+ * Determines if symlink mode is set.
+ *
+ * @return bool
+ * Whether or not 'symlink' mode
+ */
+ public function symlink() {
+ return $this->options['symlink'];
+ }
+
+ /**
+ * Determines if there are file mappings.
+ *
+ * @return bool
+ * Whether or not the scaffold options contain any file mappings
+ */
+ public function hasFileMapping() {
+ return !empty($this->fileMapping());
+ }
+
+ /**
+ * Returns the actual file mappings.
+ *
+ * @return array
+ * File mappings for just this config type.
+ */
+ public function fileMapping() {
+ return $this->options['file-mapping'];
+ }
+
+ /**
+ * Determines if there is defined a value for the 'gitignore' option.
+ *
+ * @return bool
+ * Whether or not there is a 'gitignore' option setting
+ */
+ public function hasGitIgnore() {
+ return isset($this->options['gitignore']);
+ }
+
+ /**
+ * Gets the value of the 'gitignore' option.
+ *
+ * @return bool
+ * The 'gitignore' option, or TRUE if undefined.
+ */
+ public function gitIgnore() {
+ return $this->hasGitIgnore() ? $this->options['gitignore'] : TRUE;
+ }
+
+}
diff --git a/core/lib/Drupal/Component/Scaffold/TESTING.txt b/core/lib/Drupal/Component/Scaffold/TESTING.txt
new file mode 100644
index 0000000000..3186f0f901
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/TESTING.txt
@@ -0,0 +1,18 @@
+HOW-TO: Test this Drupal component
+
+In order to test this component, you'll need to get the entire Drupal repo and
+run the tests there.
+
+You'll find the tests under core/tests/Drupal/Tests/Component.
+
+You can get the full Drupal repo here:
+https://www.drupal.org/project/drupal/git-instructions
+
+You can find more information about running PHPUnit tests with Drupal here:
+https://www.drupal.org/node/2116263
+
+Each component in the Drupal\Component namespace has its own annotated test
+group. You can use this group to run only the tests for this component. Like
+this:
+
+$ ./vendor/bin/phpunit -c core --group Scaffold
diff --git a/core/lib/Drupal/Component/Scaffold/composer.json b/core/lib/Drupal/Component/Scaffold/composer.json
new file mode 100644
index 0000000000..e826f21d36
--- /dev/null
+++ b/core/lib/Drupal/Component/Scaffold/composer.json
@@ -0,0 +1,29 @@
+{
+ "name": "drupal/core-composer-scaffold",
+ "description": "A flexible Composer project scaffold builder.",
+ "type": "composer-plugin",
+ "keywords": ["drupal"],
+ "homepage": "https://www.drupal.org/project/drupal",
+ "license": "GPL-2.0-or-later",
+ "require": {
+ "composer-plugin-api": "^1.0.0",
+ "php": ">=7.0.8"
+ },
+ "autoload": {
+ "psr-4": {
+ "Drupal\\Component\\Scaffold\\": ""
+ }
+ },
+ "extra": {
+ "class": "Drupal\\Component\\Scaffold\\Plugin",
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "require-dev": {
+ "composer/composer": "^1.8@stable"
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/AssertUtilsTrait.php b/core/tests/Drupal/Tests/Component/Scaffold/AssertUtilsTrait.php
new file mode 100644
index 0000000000..3fdb950e36
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/AssertUtilsTrait.php
@@ -0,0 +1,27 @@
+assertFileExists($path);
+ $contents = file_get_contents($path);
+ $this->assertRegExp($contents_contains, basename($path) . ': ' . $contents);
+ $this->assertSame($is_link, is_link($path));
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/ExecTrait.php b/core/tests/Drupal/Tests/Component/Scaffold/ExecTrait.php
new file mode 100644
index 0000000000..0986961788
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/ExecTrait.php
@@ -0,0 +1,36 @@
+ getenv('PATH'), 'HOME' => getenv('HOME')]);
+ $process->inheritEnvironmentVariables();
+ $process->setTimeout(300)->setIdleTimeout(300)->run();
+ $exitCode = $process->getExitCode();
+ if (0 != $exitCode) {
+ throw new \RuntimeException("Exit code: {$exitCode}\n\n" . $process->getErrorOutput() . "\n\n" . $process->getOutput());
+ }
+ return [$process->getOutput(), $process->getErrorOutput()];
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Fixtures.php b/core/tests/Drupal/Tests/Component/Scaffold/Fixtures.php
new file mode 100644
index 0000000000..de91e9381b
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Fixtures.php
@@ -0,0 +1,368 @@
+io) {
+ $this->io = new BufferIO();
+ }
+ return $this->io;
+ }
+
+ /**
+ * Gets the Composer object.
+ *
+ * @return \Composer\Composer
+ * The main Composer object, needed by the scaffold Handler, etc.
+ */
+ public function getComposer() {
+ if (!$this->composer) {
+ $this->composer = Factory::create($this->io(), NULL, TRUE);
+ }
+ return $this->composer;
+ }
+
+ /**
+ * Gets the output from the io() fixture.
+ *
+ * @return string
+ * Output captured from tests that write to Fixtures::io().
+ */
+ public function getOutput() {
+ return $this->io()->getOutput();
+ }
+
+ /**
+ * Gets the path to Scaffold component.
+ *
+ * Used to inject the component into composer.json files.
+ *
+ * @return string
+ * Path to the root of this project.
+ */
+ public function projectRoot() {
+ return realpath(__DIR__) . '/../../../../../../core/lib/Drupal/Component/Scaffold';
+ }
+
+ /**
+ * Gets the path to the project fixtures.
+ *
+ * @return string
+ * Path to project fixtures
+ */
+ public function allFixturesDir() {
+ return realpath(__DIR__ . '/fixtures');
+ }
+
+ /**
+ * Gets the path to one particular project fixture.
+ *
+ * @param string $project_name
+ * The project name to get the path for.
+ *
+ * @return string
+ * Path to project fixture.
+ */
+ public function projectFixtureDir($project_name) {
+ $dir = $this->allFixturesDir() . '/' . $project_name;
+ if (!is_dir($dir)) {
+ throw new \RuntimeException("Requested fixture project {$project_name} that does not exist.");
+ }
+ return $dir;
+ }
+
+ /**
+ * Gets the path to one particular bin path.
+ *
+ * @param string $bin_name
+ * The bin name to get the path for.
+ *
+ * @return string
+ * Path to project fixture.
+ */
+ public function binFixtureDir($bin_name) {
+ $dir = $this->allFixturesDir() . '/scripts/' . $bin_name;
+ if (!is_dir($dir)) {
+ throw new \RuntimeException("Requested fixture bin dir {$bin_name} that does not exist.");
+ }
+ return $dir;
+ }
+
+ /**
+ * Gets a path to a source scaffold fixture.
+ *
+ * Use in place of ScaffoldFilePath::sourcePath().
+ *
+ * @param string $project_name
+ * The name of the project to fetch; $package_name is
+ * "fixtures/$project_name".
+ * @param string $source
+ * The name of the asset; path is "assets/$source".
+ *
+ * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+ * The full and relative path to the desired asset
+ *
+ * @see \Drupal\Component\Scaffold\ScaffoldFilePath::sourcePath()
+ */
+ public function sourcePath($project_name, $source) {
+ $package_name = "fixtures/{$project_name}";
+ $source_rel_path = "assets/{$source}";
+ $package_path = $this->projectFixtureDir($project_name);
+ return ScaffoldFilePath::sourcePath($package_name, $package_path, 'unknown', $source_rel_path);
+ }
+
+ /**
+ * Gets an Interpolator with 'web-root' and 'package-name' set.
+ *
+ * Use in place of ManageOptions::getLocationReplacements().
+ *
+ * @return \Drupal\Component\Scaffold\Interpolator
+ * An interpolator with location replacements, including 'web-root'.
+ *
+ * @see \Drupal\Component\Scaffold\ManageOptions::getLocationReplacements()
+ */
+ public function getLocationReplacements() {
+ $destinationTmpDir = $this->mkTmpDir();
+ $interpolator = new Interpolator();
+ $interpolator->setData(['web-root' => $destinationTmpDir, 'package-name' => 'fixtures/tmp-destination']);
+ return $interpolator;
+ }
+
+ /**
+ * Creates a ReplaceOp fixture.
+ *
+ * @param string $project_name
+ * The name of the project to fetch; $package_name is
+ * "fixtures/$project_name".
+ * @param string $source
+ * The name of the asset; path is "assets/$source".
+ *
+ * @return \Drupal\Component\Scaffold\Operations\ReplaceOp
+ * A replace operation object.
+ */
+ public function replaceOp($project_name, $source) {
+ $source_path = $this->sourcePath($project_name, $source);
+ return new ReplaceOp($source_path, TRUE);
+ }
+
+ /**
+ * Creates an AppendOp fixture.
+ *
+ * @param string $project_name
+ * The name of the project to fetch; $package_name is
+ * "fixtures/$project_name".
+ * @param string $source
+ * The name of the asset; path is "assets/$source".
+ *
+ * @return \Drupal\Component\Scaffold\Operations\AppendOp
+ * An append operation object.
+ */
+ public function appendOp($project_name, $source) {
+ $source_path = $this->sourcePath($project_name, $source);
+ return new AppendOp(NULL, $source_path);
+ }
+
+ /**
+ * Gets a destination path in a tmp dir.
+ *
+ * Use in place of ScaffoldFilePath::destinationPath().
+ *
+ * @param string $destination
+ * Destination path; should be in the form '[web-root]/robots.txt', where
+ * '[web-root]' is always literally '[web-root]', with any arbitrarily
+ * desired filename following.
+ * @param \Drupal\Component\Scaffold\Interpolator $interpolator
+ * Location replacements. Obtain via Fixtures::getLocationReplacements()
+ * when creating multiple scaffold destinations.
+ * @param string $package_name
+ * (optional) The name of the fixture package that this path came from.
+ * Taken from interpolator if not provided.
+ *
+ * @return \Drupal\Component\Scaffold\ScaffoldFilePath
+ * A destination scaffold file backed by temporary storage.
+ *
+ * @see \Drupal\Component\Scaffold\ScaffoldFilePath::destinationPath()
+ */
+ public function destinationPath($destination, Interpolator $interpolator = NULL, $package_name = NULL) {
+ $interpolator = $interpolator ?: $this->getLocationReplacements();
+ $package_name = $package_name ?: $interpolator->interpolate('[package-name]');
+ return ScaffoldFilePath::destinationPath($package_name, $destination, $interpolator);
+ }
+
+ /**
+ * Generates a path to a temporary location, but do not create the directory.
+ *
+ * @param string $extraSalt
+ * Extra characters to throw into the md5 to add to name.
+ *
+ * @return string
+ * Path to temporary directory
+ */
+ public function tmpDir($extraSalt = '') {
+ $tmpDir = sys_get_temp_dir() . '/composer-scaffold-test-' . md5($extraSalt . microtime());
+ $this->tmpDirs[] = $tmpDir;
+ return $tmpDir;
+ }
+
+ /**
+ * Creates a temporary directory.
+ *
+ * @param string $extraSalt
+ * Extra characters to throw into the md5 to add to name.
+ *
+ * @return string
+ * Path to temporary directory
+ */
+ public function mkTmpDir($extraSalt = '') {
+ $tmpDir = $this->tmpDir($extraSalt);
+ $filesystem = new Filesystem();
+ $filesystem->ensureDirectoryExists($tmpDir);
+ return $tmpDir;
+ }
+
+ /**
+ * Calls 'tearDown' in any test that copies fixtures to transient locations.
+ */
+ public function tearDown() {
+ // Remove any temporary directories that were created.
+ $filesystem = new Filesystem();
+ foreach ($this->tmpDirs as $dir) {
+ $filesystem->remove($dir);
+ }
+ // Clear out variables from the previous pass.
+ $this->tmpDirs = [];
+ $this->io = NULL;
+ }
+
+ /**
+ * Creates a temporary copy of all of the fixtures projects into a temp dir.
+ *
+ * The fixtures remain dirty if they already exist. Individual tests should
+ * first delete any fixture directory that needs to remain pristine. Since all
+ * temporary directories are removed in tearDown, this is only an issue when
+ * a) the FIXTURE_DIR environment variable has been set, or b) tests are
+ * calling cloneFixtureProjects more than once per test method.
+ *
+ * @param string $fixturesDir
+ * The directory to place fixtures in.
+ * @param array $replacements
+ * Key : value mappings for placeholders to replace in composer.json
+ * templates.
+ */
+ public function cloneFixtureProjects($fixturesDir, array $replacements = []) {
+ $filesystem = new Filesystem();
+ $replacements += ['SYMLINK' => 'true'];
+ $interpolator = new Interpolator('__', '__');
+ $interpolator->setData($replacements);
+ $filesystem->copy($this->allFixturesDir(), $fixturesDir);
+ $composer_json_templates = glob($fixturesDir . "/*/composer.json.tmpl");
+ foreach ($composer_json_templates as $composer_json_tmpl) {
+ // Inject replacements into composer.json.
+ if (file_exists($composer_json_tmpl)) {
+ $composer_json_contents = file_get_contents($composer_json_tmpl);
+ $composer_json_contents = $interpolator->interpolate($composer_json_contents, [], FALSE);
+ file_put_contents(dirname($composer_json_tmpl) . "/composer.json", $composer_json_contents);
+ @unlink($composer_json_tmpl);
+ }
+ }
+ }
+
+ /**
+ * Runs the scaffold operation.
+ *
+ * This is equivalent to running `composer composer-scaffold`, but we do the
+ * equivalent operation by instantiating a Handler object in order to continue
+ * running in the same process, so that coverage may be calculated for the
+ * code executed by these tests.
+ *
+ * @param string $cwd
+ * The working directory to run the scaffold command in.
+ *
+ * @return string
+ * Output captured from tests that write to Fixtures::io().
+ */
+ public function runScaffold($cwd) {
+ chdir($cwd);
+ $handler = new Handler($this->getComposer(), $this->io());
+ $handler->scaffold();
+ return $this->getOutput();
+ }
+
+ /**
+ * Runs a `composer` command.
+ *
+ * @param string $cmd
+ * The Composer command to execute (escaped as required)
+ * @param string $cwd
+ * The current working directory to run the command from.
+ * @param int $expectedExitCode
+ * The expected exit code; will throw if a different exit code is returned.
+ *
+ * @return string
+ * Standard output and standard error from the command.
+ */
+ public function runComposer($cmd, $cwd, $expectedExitCode = 0) {
+ chdir($cwd);
+ $input = new StringInput($cmd);
+ $output = new BufferedOutput();
+ $application = new Application();
+ $application->setAutoExit(FALSE);
+ try {
+ $exitCode = $application->run($input, $output);
+ if ($exitCode != $expectedExitCode) {
+ print "Command '{$cmd}' - Expected exit code: {$expectedExitCode}, actual exit code: {$exitCode}\n";
+ }
+ }
+ catch (\Exception $e) {
+ print "Exception: " . $e->getMessage() . "\n";
+ }
+ $output = $output->fetch();
+ return $output;
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Functional/ComposerHookTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ComposerHookTest.php
new file mode 100644
index 0000000000..c48d8688c7
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ComposerHookTest.php
@@ -0,0 +1,147 @@
+fileSystem = new Filesystem();
+ $this->fixtures = new Fixtures();
+ $this->projectRoot = $this->fixtures->projectRoot();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function tearDown() {
+ // Remove any temporary directories et. al. that were created.
+ $this->fixtures->tearDown();
+ }
+
+ /**
+ * Test to see if scaffold operation runs at the correct times.
+ */
+ public function testComposerHooks() {
+ $this->fixturesDir = $this->fixtures->tmpDir($this->getName());
+ $is_link = FALSE;
+ $replacements = ['SYMLINK' => $is_link ? 'true' : 'false', 'PROJECT_ROOT' => $this->projectRoot];
+ $this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
+ $topLevelProjectDir = 'composer-hooks-fixture';
+ $sut = $this->fixturesDir . '/' . $topLevelProjectDir;
+ // First test: run composer install. This is the same as composer update
+ // since there is no lock file. Ensure that scaffold operation ran.
+ $this->execComposer("install --no-ansi", $sut);
+ $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', $is_link, '#Test version of default.settings.php from drupal/core#');
+ // Run composer required to add in the scaffold-override-fixture. This
+ // project is "allowed" in our main fixture project, but not required.
+ // We expect that requiring this library should re-scaffold, resulting
+ // in a changed default.settings.php file.
+ list($stdout,) = $this->execComposer("require --no-ansi --no-interaction fixtures/scaffold-override-fixture:dev-master", $sut);
+ $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', $is_link, '#scaffolded from the scaffold-override-fixture#');
+ // Make sure that the appropriate notice informing us that scaffolding
+ // is allowed was printed.
+ $this->assertContains('Package fixtures/scaffold-override-fixture has scaffold operations, and is already allowed in the root-level composer.json file.', $stdout);
+ // Delete one scaffold file, just for test purposes, then run
+ // 'composer update' and see if the scaffold file is replaced.
+ @unlink($sut . '/sites/default/default.settings.php');
+ $this->execComposer("update --no-ansi", $sut);
+ $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', $is_link, '#scaffolded from the scaffold-override-fixture#');
+ // Delete the same test scaffold file again, then run
+ // 'composer composer:scaffold' and see if the scaffold file is replaced.
+ @unlink($sut . '/sites/default/default.settings.php');
+ $this->execComposer("composer:scaffold --no-ansi", $sut);
+ $this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', $is_link, '#scaffolded from the scaffold-override-fixture#');
+ // Run 'composer create-project' to create a new test project called
+ // 'create-project-test', which is a copy of 'fixtures/drupal-drupal'.
+ $sut = $this->fixturesDir . '/create-project-test';
+ $filesystem = new Filesystem();
+ $filesystem->remove($sut);
+ list($stdout,) = $this->execComposer("create-project --repository=packages.json fixtures/drupal-drupal {$sut}", $this->fixturesDir, ['COMPOSER_MIRROR_PATH_REPOS' => 1]);
+ $this->assertDirectoryExists($sut);
+ $this->assertContains('Scaffolding files for fixtures/drupal-drupal', $stdout);
+ $this->assertScaffoldedFile($sut . '/index.php', FALSE, '#Test version of index.php from drupal/core#');
+ $topLevelProjectDir = 'composer-hooks-nothing-allowed-fixture';
+ $sut = $this->fixturesDir . '/' . $topLevelProjectDir;
+ // Run composer install on an empty project.
+ $this->execComposer("install --no-ansi", $sut);
+ // Require a project that is not allowed to scaffold and confirm that we
+ // get a warning, and it does not scaffold.
+ list($stdout,) = $this->execComposer("require --no-ansi --no-interaction fixtures/scaffold-override-fixture:dev-master", $sut);
+ $this->assertFileNotExists($sut . '/sites/default/default.settings.php');
+ $this->assertContains('Package fixtures/scaffold-override-fixture has scaffold operations, but it is not allowed in the root-level composer.json file.', $stdout);
+ }
+
+ /**
+ * Runs a `composer` command.
+ *
+ * @param string $cmd
+ * The Composer command to execute (escaped as required)
+ * @param string $cwd
+ * The current working directory to run the command from.
+ * @param array $env
+ * Environment variables to define for the subprocess.
+ *
+ * @return array
+ * Standard output and standard error from the command
+ */
+ protected function execComposer($cmd, $cwd, array $env = []) {
+ return $this->mustExec("composer {$cmd}", $cwd, $env);
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Functional/ManageGitIgnoreTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ManageGitIgnoreTest.php
new file mode 100644
index 0000000000..fc6f63f8c7
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ManageGitIgnoreTest.php
@@ -0,0 +1,191 @@
+fileSystem = new Filesystem();
+ $this->fixtures = new Fixtures();
+ $this->projectRoot = $this->fixtures->projectRoot();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function tearDown() {
+ // Remove any temporary directories et. al. that were created.
+ $this->fixtures->tearDown();
+ }
+
+ /**
+ * Creates a system-under-test and initialize a git repository for it.
+ *
+ * @param string $fixture_name
+ * The name of the fixture to use from
+ * core/tests/Drupal/Tests/Component/Scaffold/fixtures.
+ *
+ * @return string
+ * The path to the fixture directory.
+ */
+ protected function createSutWithGit($fixture_name) {
+ $is_link = FALSE;
+ $this->fixturesDir = $this->fixtures->tmpDir($this->getName());
+ $sut = $this->fixturesDir . '/' . $fixture_name;
+ $replacements = ['SYMLINK' => $is_link ? 'true' : 'false', 'PROJECT_ROOT' => $this->projectRoot];
+ $this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
+ // .gitignore files will not be managed unless there is a git repository.
+ $this->mustExec('git init', $sut);
+ // Add some user info so git does not complain.
+ $this->mustExec('git config user.email "test@example.com"', $sut);
+ $this->mustExec('git config user.name "Test User"', $sut);
+ $this->mustExec('git add .', $sut);
+ $this->mustExec('git commit -m "Initial commit."', $sut);
+ // Run composer install, but suppress scaffolding.
+ $this->fixtures->runComposer("install --no-ansi --no-scripts", $sut);
+ return $sut;
+ }
+
+ /**
+ * Tests scaffold command correctly manages the .gitignore file.
+ */
+ public function testManageGitIgnore() {
+ // Note that the drupal-composer-drupal-project fixture does not
+ // have any configuration settings related to .gitignore management.
+ $sut = $this->createSutWithGit('drupal-composer-drupal-project');
+ $this->assertFileNotExists($sut . '/docroot/index.php');
+ $this->assertFileNotExists($sut . '/docroot/sites/.gitignore');
+ // Run the scaffold command.
+ $this->fixtures->runScaffold($sut);
+ $this->assertFileExists($sut . '/docroot/index.php');
+ $expected = <<assertScaffoldedFile($sut . '/docroot/.gitignore', FALSE, '#' . $expected . '#msi');
+ $this->assertScaffoldedFile($sut . '/docroot/sites/.gitignore', FALSE, '#example.settings.local.php#');
+ $this->assertScaffoldedFile($sut . '/docroot/sites/default/.gitignore', FALSE, '#default.services.yml#');
+ $expected = <<mustExec('git status --porcelain', $sut);
+ $this->assertEquals(trim($expected), trim($stdout));
+ }
+
+ /**
+ * Tests scaffold command does not manage the .gitignore file when disabled.
+ */
+ public function testUnmanagedGitIgnoreWhenDisabled() {
+ // Note that the drupal-drupal fixture has a configuration setting
+ // `"gitignore": false,` which disables .gitignore file handling.
+ $sut = $this->createSutWithGit('drupal-drupal');
+ $this->assertFileNotExists($sut . '/docroot/index.php');
+ // Run the scaffold command.
+ $this->fixtures->runScaffold($sut);
+ $this->assertFileExists($sut . '/index.php');
+ $this->assertFileNotExists($sut . '/.gitignore');
+ $this->assertFileNotExists($sut . '/docroot/sites/default/.gitignore');
+ }
+
+ /**
+ * Tests scaffold command disables .gitignore management when git not present.
+ *
+ * The scaffold operation should still succeed if there is no 'git'
+ * executable.
+ */
+ public function testUnmanagedGitIgnoreWhenGitNotAvailable() {
+ // Note that the drupal-composer-drupal-project fixture does not have any
+ // configuration settings related to .gitignore management.
+ $sut = $this->createSutWithGit('drupal-composer-drupal-project');
+ $this->assertFileNotExists($sut . '/docroot/sites/default/.gitignore');
+ $this->assertFileNotExists($sut . '/docroot/index.php');
+ $this->assertFileNotExists($sut . '/docroot/sites/.gitignore');
+ // Confirm that 'git' is available (n.b. if it were not, createSutWithGit()
+ // would fail).
+ exec('git --help', $output, $status);
+ $this->assertEquals(0, $status);
+ // Modify our $PATH so that it begins with a path that contains an
+ // executable script named 'git' that always exits with 127, as if git were
+ // not found. Note that we run our tests using process isolation, so we do
+ // not need to restore the PATH when we are done.
+ $unavailableGitPath = $this->fixtures->binFixtureDir('disable-git-bin');
+ chmod($unavailableGitPath . '/git', 0755);
+ putenv('PATH=' . $unavailableGitPath . ':' . getenv('PATH'));
+ // Confirm that 'git' is no longer available.
+ exec('git --help', $output, $status);
+ $this->assertEquals(127, $status);
+ // Run the scaffold command.
+ exec('composer composer:scaffold', $output, $status);
+ $this->assertFileExists($sut . '/docroot/index.php');
+ $this->assertFileNotExists($sut . '/docroot/sites/default/.gitignore');
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Functional/ScaffoldTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ScaffoldTest.php
new file mode 100644
index 0000000000..0c18b2b5ff
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Functional/ScaffoldTest.php
@@ -0,0 +1,384 @@
+fileSystem = new Filesystem();
+ $this->fixtures = new Fixtures();
+ $this->projectRoot = $this->fixtures->projectRoot();
+ // The directory used for creating composer projects to test can be
+ // configured using the SCAFFOLD_FIXTURE_DIR environment variable. Otherwise
+ // a directory will be created in the system's temporary directory.
+ $this->fixturesDir = getenv('SCAFFOLD_FIXTURE_DIR');
+ if (!$this->fixturesDir) {
+ $this->fixturesDir = $this->fixtures->tmpDir($this->getName());
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function tearDown() {
+ // Remove any temporary directories et. al. that were created.
+ $this->fixtures->tearDown();
+ }
+
+ /**
+ * Creates the System-Under-Test.
+ *
+ * @param string $fixture_name
+ * The name of the fixture to use from
+ * core/tests/Drupal/Tests/Component/Scaffold/fixtures.
+ * @param array $replacements
+ * Key : value mappings for placeholders to replace in composer.json
+ * templates.
+ *
+ * @return string
+ * The path to the created System-Under-Test.
+ */
+ protected function createSut($fixture_name, array $replacements = []) {
+ $sut = $this->fixturesDir . '/' . $fixture_name;
+ // Erase just our sut, to ensure it is clean. Recopy all of the fixtures.
+ $this->fileSystem->remove($sut);
+ $replacements += ['PROJECT_ROOT' => $this->projectRoot];
+ $this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
+ return $sut;
+ }
+
+ /**
+ * Data provider for testComposerInstallScaffold and testScaffoldCommand.
+ */
+ public function scaffoldFixturesWithErrorConditionsTestValues() {
+ return [
+ [
+ 'drupal-drupal-missing-scaffold-file',
+ 'Scaffold file assets/missing-robots-default.txt not found in package fixtures/drupal-drupal-missing-scaffold-file.',
+ TRUE,
+ ],
+ ];
+ }
+
+ /**
+ * Tests that scaffold files throw when they have bad values.
+ *
+ * @param string $fixture_name
+ * The name of the fixture to use from
+ * core/tests/Drupal/Tests/Component/Scaffold/fixtures.
+ * @param string $expected_exception_message
+ * The expected exception message.
+ * @param bool $is_link
+ * Whether or not symlinking should be used.
+ *
+ * @dataProvider scaffoldFixturesWithErrorConditionsTestValues
+ */
+ public function testScaffoldFixturesWithErrorConditions($fixture_name, $expected_exception_message, $is_link) {
+ $sut = $this->createSut($fixture_name, ['SYMLINK' => $is_link ? 'true' : 'false']);
+ // Run composer install to get the dependencies we need to test.
+ $this->fixtures->runComposer("install --no-ansi --no-scripts", $sut);
+ // Test scaffold. Expect an error.
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage($expected_exception_message);
+ $this->fixtures->runScaffold($sut);
+ }
+
+ /**
+ * Data provider for testComposerInstallScaffold and testScaffoldCommand.
+ */
+ public function scaffoldTestValues() {
+ return [
+ [
+ 'drupal-composer-drupal-project',
+ 'assertDrupalProjectSutWasScaffolded',
+ TRUE,
+ ],
+ [
+ 'drupal-drupal',
+ 'assertDrupalDrupalSutWasScaffolded',
+ FALSE,
+ ],
+ [
+ 'drupal-drupal-test-overwrite',
+ 'assertDrupalDrupalFileWasReplaced',
+ FALSE,
+ ],
+ [
+ 'drupal-drupal-test-append',
+ 'assertDrupalDrupalFileWasAppended',
+ FALSE,
+ ],
+ [
+ 'drupal-drupal-append-to-append',
+ 'assertFileWasAppendedToAppendedFile',
+ FALSE,
+ ],
+ [
+ 'drupal-drupal-test-append',
+ 'assertDrupalDrupalFileWasAppended',
+ TRUE,
+ ],
+ ];
+ }
+
+ /**
+ * Tests that scaffold files are correctly moved.
+ *
+ * @param string $fixture_name
+ * The name of the fixture to use from
+ * core/tests/Drupal/Tests/Component/Scaffold/fixtures.
+ * @param string $assert_function
+ * The name of the assert function to call after running the scaffold
+ * command.
+ * @param bool $is_link
+ * Whether to use symlinking.
+ *
+ * @dataProvider scaffoldTestValues
+ */
+ public function testScaffold($fixture_name, $assert_function, $is_link) {
+ $sut = $this->createSut($fixture_name, ['SYMLINK' => $is_link ? 'true' : 'false']);
+ // Run composer install to get the dependencies we need to test.
+ $this->fixtures->runComposer("install --no-ansi --no-scripts", $sut);
+ // Test composer:scaffold.
+ $this->fixtures->runScaffold($sut);
+ // @todo We could assert that $scaffoldOutput must contain some expected text
+ call_user_func([$this, $assert_function], $sut, $is_link, $fixture_name);
+ }
+
+ /**
+ * Try to scaffold a project that does not scaffold anything.
+ */
+ public function testEmptyProject() {
+ $topLevelProjectDir = 'empty-fixture';
+ $sut = $this->createSut($topLevelProjectDir, ['SYMLINK' => 'false']);
+ // Run composer install to get the dependencies we need to test.
+ $this->fixtures->runComposer("install --no-ansi --no-scripts", $sut);
+ // Test composer:scaffold.
+ $scaffoldOutput = $this->fixtures->runScaffold($sut);
+ $this->assertContains('Nothing scaffolded because no packages are allowed in the top-level composer.json file', $scaffoldOutput);
+ }
+
+ /**
+ * Try to scaffold a project that allows a project with no scaffold files.
+ */
+ public function testProjectThatScaffoldsEmptyProject() {
+ $topLevelProjectDir = 'project-allowing-empty-fixture';
+ $sut = $this->createSut($topLevelProjectDir, ['SYMLINK' => 'false']);
+ // Run composer install to get the dependencies we need to test.
+ $this->fixtures->runComposer("install --no-ansi --no-scripts", $sut);
+ // Test composer:scaffold.
+ $scaffoldOutput = $this->fixtures->runScaffold($sut);
+ $this->assertContains('The allowed package fixtures/empty-fixture does not provide a file mapping for Composer Scaffold', $scaffoldOutput);
+ $docroot = $sut;
+ $this->assertCommonDrupalAssetsWereScaffolded($docroot, FALSE);
+ }
+
+ /**
+ * Try to scaffold a project that attempts to scaffold a file with no path.
+ */
+ public function testProjectWithEmptyScaffoldPath() {
+ $topLevelProjectDir = 'project-with-empty-scaffold-path';
+ $sut = $this->createSut($topLevelProjectDir, ['SYMLINK' => 'false']);
+ // Run composer install to get the dependencies we need to test.
+ $this->fixtures->runComposer("install --no-ansi --no-scripts", $sut);
+ // Test composer:scaffold.
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('No scaffold file path given for [web-root]/my-error in package fixtures/project-with-empty-scaffold-path');
+ $this->fixtures->runScaffold($sut);
+ }
+
+ /**
+ * Try to scaffold a project that attempts to scaffold a directory.
+ */
+ public function testProjectWithIllegalDirScaffold() {
+ $topLevelProjectDir = 'project-with-illegal-dir-scaffold';
+ $sut = $this->createSut($topLevelProjectDir, ['SYMLINK' => 'false']);
+ // Run composer install to get the dependencies we need to test.
+ $this->fixtures->runComposer("install --no-ansi --no-scripts", $sut);
+ // Test composer:scaffold.
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Scaffold file assets in package fixtures/project-with-illegal-dir-scaffold is a directory; only files may be scaffolded');
+ $this->fixtures->runScaffold($sut);
+ }
+
+ /**
+ * Asserts that the drupal/assets scaffold files correct for drupal/project.
+ *
+ * @param string $sut
+ * The path to the System-under-Test.
+ * @param bool $is_link
+ * Whether or not symlinking is used.
+ * @param string $project_name
+ * Not used but this assertion is called dynamically and this argument is
+ * provided.
+ */
+ protected function assertDrupalProjectSutWasScaffolded($sut, $is_link, $project_name) {
+ $docroot = $sut . '/docroot';
+ $this->assertCommonDrupalAssetsWereScaffolded($docroot, $is_link);
+ $this->assertDefaultSettingsFromScaffoldOverride($docroot, $is_link);
+ $this->assertHtaccessExcluded($docroot);
+ }
+
+ /**
+ * Asserts that the drupal/assets scaffold files correct for drupal/drupal.
+ *
+ * @param string $sut
+ * The path to the System-under-Test.
+ * @param bool $is_link
+ * Whether or not symlinking is used.
+ * @param string $project_name
+ * Not used but this assertion is called dynamically and this argument is
+ * provided.
+ */
+ protected function assertDrupalDrupalSutWasScaffolded($sut, $is_link, $project_name) {
+ $docroot = $sut;
+ $this->assertCommonDrupalAssetsWereScaffolded($docroot, $is_link);
+ $this->assertDefaultSettingsFromScaffoldOverride($docroot, $is_link);
+ $this->assertHtaccessExcluded($docroot);
+ }
+
+ /**
+ * Asserts that the default settings file was overridden by the test.
+ */
+ protected function assertDefaultSettingsFromScaffoldOverride($docroot, $is_link) {
+ $this->assertScaffoldedFile($docroot . '/sites/default/default.settings.php', $is_link, '#scaffolded from the scaffold-override-fixture#');
+ }
+
+ /**
+ * Asserts that the .htaccess file was excluded by the test.
+ *
+ * @param string $docroot
+ * The path to the System-under-Test's docroot.
+ */
+ protected function assertHtaccessExcluded($docroot) {
+ // Ensure that the .htaccess.txt file was not written, as our
+ // top-level composer.json excludes it from the files to scaffold.
+ $this->assertFileNotExists($docroot . '/.htaccess');
+ }
+
+ /**
+ * Asserts that the appropriate file was replaced.
+ *
+ * Check the drupal/drupal-based project to confirm that the expected file was
+ * replaced, and that files that were not supposed to be replaced remain
+ * unchanged.
+ *
+ * @param string $sut
+ * The path to the System-under-Test.
+ * @param bool $is_link
+ * Whether or not symlinking is used.
+ * @param string $project_name
+ * The project name expected in the robots.txt file.
+ */
+ protected function assertDrupalDrupalFileWasReplaced($sut, $is_link, $project_name) {
+ $docroot = $sut;
+ $this->assertScaffoldedFile($docroot . '/replace-me.txt', $is_link, '#from assets that replaces file#');
+ $this->assertScaffoldedFile($docroot . '/keep-me.txt', $is_link, '#File in drupal-drupal-test-overwrite that is not replaced#');
+ $this->assertScaffoldedFile($docroot . '/make-me.txt', $is_link, '#from assets that replaces file#');
+ $this->assertCommonDrupalAssetsWereScaffolded($docroot, $is_link);
+ $this->assertScaffoldedFile($docroot . '/robots.txt', $is_link, "#{$project_name}#");
+ }
+
+ /**
+ * Asserts that the robots.txt file was prepended / appended as expected.
+ *
+ * @param string $sut
+ * The path to the System-under-Test.
+ * @param bool $is_link
+ * Whether or not symlinking is used.
+ * @param string $project_name
+ * Not used but this assertion is called dynamically and this argument is
+ * provided.
+ */
+ protected function assertDrupalDrupalFileWasAppended($sut, $is_link, $project_name) {
+ $docroot = $sut;
+ $this->assertScaffoldedFile($docroot . '/robots.txt', FALSE, '#in drupal-drupal-test-append composer.json fixture.*This content is prepended to the top of the existing robots.txt fixture.*Test version of robots.txt from drupal/core.*This content is appended to the bottom of the existing robots.txt fixture.*in drupal-drupal-test-append composer.json fixture#ms');
+ $this->assertCommonDrupalAssetsWereScaffolded($docroot, $is_link);
+ }
+
+ protected function assertFileWasAppendedToAppendedFile($sut, $is_link, $project_name) {
+ $docroot = $sut;
+ $this->assertScaffoldedFile($docroot . '/robots.txt', FALSE, '#in drupal-drupal-append-to-append composer.json fixture.*This content is prepended to the top of the existing robots.txt fixture.*Test version of robots.txt from drupal/core.*This content is appended to the bottom of the existing robots.txt fixture.*in profile-with-append composer.json fixture.*This content is appended to the bottom of the existing robots.txt fixture.*in drupal-drupal-append-to-append composer.json fixture#ms');
+ $this->assertCommonDrupalAssetsWereScaffolded($docroot, $is_link, $project_name);
+ }
+
+ /**
+ * Asserts that the scaffold files from drupal/assets are placed as expected.
+ *
+ * This tests that all assets from drupal/assets were scaffolded, save
+ * for .htaccess, robots.txt and default.settings.php, which are scaffolded
+ * in different ways in different tests.
+ *
+ * @param string $docroot
+ * The path to the System-under-Test's docroot.
+ * @param bool $is_link
+ * Whether or not symlinking is used.
+ */
+ protected function assertCommonDrupalAssetsWereScaffolded($docroot, $is_link) {
+ $from_core = '#from drupal/core#';
+ // Ensure that the autoload.php file was written.
+ $this->assertFileExists($docroot . '/autoload.php');
+ // Assert other scaffold files are written in the correct locations.
+ $this->assertScaffoldedFile($docroot . '/.csslintrc', $is_link, $from_core);
+ $this->assertScaffoldedFile($docroot . '/.editorconfig', $is_link, $from_core);
+ $this->assertScaffoldedFile($docroot . '/.eslintignore', $is_link, $from_core);
+ $this->assertScaffoldedFile($docroot . '/.eslintrc.json', $is_link, $from_core);
+ $this->assertScaffoldedFile($docroot . '/.gitattributes', $is_link, $from_core);
+ $this->assertScaffoldedFile($docroot . '/.ht.router.php', $is_link, $from_core);
+ $this->assertScaffoldedFile($docroot . '/sites/default/default.services.yml', $is_link, $from_core);
+ $this->assertScaffoldedFile($docroot . '/sites/example.settings.local.php', $is_link, $from_core);
+ $this->assertScaffoldedFile($docroot . '/sites/example.sites.php', $is_link, $from_core);
+ $this->assertScaffoldedFile($docroot . '/index.php', $is_link, $from_core);
+ $this->assertScaffoldedFile($docroot . '/update.php', $is_link, $from_core);
+ $this->assertScaffoldedFile($docroot . '/web.config', $is_link, $from_core);
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Integration/AppendOpTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Integration/AppendOpTest.php
new file mode 100644
index 0000000000..e381cd59ce
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Integration/AppendOpTest.php
@@ -0,0 +1,58 @@
+destinationPath('[web-root]/robots.txt');
+ $options = ScaffoldOptions::create([]);
+ // Assert that there is no target file before we run our test.
+ $this->assertFileNotExists($destination->fullPath());
+
+ // Create a file.
+ file_put_contents($destination->fullPath(), "# This is a test\n");
+
+ $prepend = $fixtures->sourcePath('drupal-drupal-test-append', 'prepend-to-robots.txt');
+ $append = $fixtures->sourcePath('drupal-drupal-test-append', 'append-to-robots.txt');
+ $sut = new AppendOp($prepend, $append);
+
+ // Test the system under test.
+ $sut->process($destination, $fixtures->io(), $options);
+ // Assert that the target file was created.
+ $this->assertFileExists($destination->fullPath());
+ // Assert the target contained the contents from the correct scaffold files.
+ $contents = trim(file_get_contents($destination->fullPath()));
+ $expected = <<assertEquals(trim($expected), $contents);
+ // Confirm that expected output was written to our io fixture.
+ $output = $fixtures->getOutput();
+ $this->assertContains('Prepend to [web-root]/robots.txt from assets/prepend-to-robots.txt', $output);
+ $this->assertContains('Append to [web-root]/robots.txt from assets/append-to-robots.txt', $output);
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Integration/OperationCollectionTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Integration/OperationCollectionTest.php
new file mode 100644
index 0000000000..2a690a0e6f
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Integration/OperationCollectionTest.php
@@ -0,0 +1,130 @@
+getLocationReplacements();
+ $file_mappings = [
+ 'fixtures/drupal-assets-fixture' => [
+ '[web-root]/index.php' => $fixtures->replaceOp('drupal-assets-fixture', 'index.php'),
+ '[web-root]/.htaccess' => $fixtures->replaceOp('drupal-assets-fixture', '.htaccess'),
+ '[web-root]/robots.txt' => $fixtures->replaceOp('drupal-assets-fixture', 'robots.txt'),
+ '[web-root]/sites/default/default.services.yml' => $fixtures->replaceOp('drupal-assets-fixture', 'default.services.yml'),
+ ],
+ 'fixtures/drupal-profile' => [
+ '[web-root]/sites/default/default.services.yml' => $fixtures->replaceOp('drupal-profile', 'profile.default.services.yml'),
+ ],
+ 'fixtures/drupal-drupal' => [
+ '[web-root]/.htaccess' => new SkipOp(),
+ '[web-root]/robots.txt' => $fixtures->appendOp('drupal-drupal-test-append', 'append-to-robots.txt'),
+ ],
+ ];
+ $sut = new OperationCollection($fixtures->io());
+ // Test the system under test.
+ list($scaffold_list, $resolved_file_mappings) = $this->callProtected($sut, 'collateScaffoldFiles', [$file_mappings, $locationReplacements]);
+ // Confirm that the keys of the output are the same as the keys of the
+ // input.
+ $this->assertEquals(array_keys($file_mappings), array_keys($resolved_file_mappings));
+ // Also assert that we have the right ScaffoldFileInfo objects in the
+ // destination.
+ $this->assertResolvedToSameOp('fixtures/drupal-assets-fixture', '[web-root]/index.php', $file_mappings, $scaffold_list, $resolved_file_mappings);
+ $this->assertResolvedToSameOp('fixtures/drupal-profile', '[web-root]/sites/default/default.services.yml', $file_mappings, $scaffold_list, $resolved_file_mappings);
+ $this->assertResolvedToSameOp('fixtures/drupal-drupal', '[web-root]/robots.txt', $file_mappings, $scaffold_list, $resolved_file_mappings);
+ // Assert that the files below have been overridden.
+ $this->assertOverridden('fixtures/drupal-assets-fixture', '[web-root]/.htaccess', $scaffold_list, $resolved_file_mappings);
+ $this->assertOverridden('fixtures/drupal-assets-fixture', '[web-root]/robots.txt', $scaffold_list, $resolved_file_mappings);
+ }
+
+ /**
+ * Checks to see if a given file was not overridden.
+ *
+ * The package name in the scaffold list for the provided destination should
+ * match the package name from the specified project.
+ *
+ * @param string $project
+ * The project to check.
+ * @param string $dest
+ * The destination to check.
+ * @param array $file_mappings
+ * The test file mappings keyed by project and destination.
+ * @param \Drupal\Component\Scaffold\ScaffoldFileInfo[] $scaffold_list
+ * The list of scaffolded files keyed by destination.
+ * @param array $resolved_file_mappings
+ * The list of resolved file mappings keyed by project and destination.
+ */
+ protected function assertResolvedToSameOp($project, $dest, array $file_mappings, array $scaffold_list, array $resolved_file_mappings) {
+ $resolved_file_info = $resolved_file_mappings[$project][$dest];
+ $this->assertEquals(get_class($resolved_file_info), ScaffoldFileInfo::class);
+ $resolved_scaffold_op = $resolved_file_info->op();
+ // If this is an append op then it will be part of a conjunction op.
+ $expected = get_class($file_mappings[$project][$dest]);
+ if ($expected == AppendOp::class) {
+ $this->assertEquals(ConjunctionOp::class, get_class($resolved_scaffold_op));
+ }
+ else {
+ $this->assertEquals($expected, get_class($resolved_scaffold_op));
+ $this->assertEquals($file_mappings[$project][$dest], $resolved_scaffold_op);
+ }
+ $this->assertEquals($project, $scaffold_list[$dest]->packageName());
+ }
+
+ /**
+ * Checks if a given file was overridden.
+ *
+ * Assert that the file in the scaffold list at the specified destination
+ * comes from a different package than the one in the file info.
+ *
+ * @param string $project
+ * The project to check.
+ * @param string $dest
+ * The destination to check.
+ * @param \Drupal\Component\Scaffold\ScaffoldFileInfo[] $scaffold_list
+ * The list of scaffolded files keyed by destination.
+ * @param array $resolved_file_mappings
+ * The list of resolved file mappings keyed by project and destination.
+ */
+ protected function assertOverridden($project, $dest, array $scaffold_list, array $resolved_file_mappings) {
+ $resolved_file_info = $resolved_file_mappings[$project][$dest];
+ $this->assertEquals(get_class($resolved_file_info), ScaffoldFileInfo::class);
+ $this->assertNotEquals($project, $scaffold_list[$dest]->packageName());
+ }
+
+ /**
+ * Uses reflection to call a protected method of an object.
+ *
+ * @param mixed $obj
+ * The object to inspect.
+ * @param string $methodName
+ * The name of the method to call.
+ * @param array $args
+ * The arguments to pass to the protected method.
+ *
+ * @return mixed
+ * The return value from the protected method.
+ */
+ protected function callProtected($obj, $methodName, array $args = []) {
+ $r = new \ReflectionMethod($obj, $methodName);
+ $r->setAccessible(TRUE);
+ return $r->invokeArgs($obj, $args);
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Integration/ReplaceOpTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Integration/ReplaceOpTest.php
new file mode 100644
index 0000000000..a45d25635e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Integration/ReplaceOpTest.php
@@ -0,0 +1,40 @@
+destinationPath('[web-root]/robots.txt');
+ $source = $fixtures->sourcePath('drupal-assets-fixture', 'robots.txt');
+ $options = ScaffoldOptions::create([]);
+ $sut = new ReplaceOp($source, TRUE);
+ // Assert that there is no target file before we run our test.
+ $this->assertFileNotExists($destination->fullPath());
+ // Test the system under test.
+ $sut->process($destination, $fixtures->io(), $options);
+ // Assert that the target file was created.
+ $this->assertFileExists($destination->fullPath());
+ // Assert the target contained the contents from the correct scaffold file.
+ $contents = trim(file_get_contents($destination->fullPath()));
+ $this->assertEquals('# Test version of robots.txt from drupal/core.', $contents);
+ // Confirm that expected output was written to our io fixture.
+ $output = $fixtures->getOutput();
+ $this->assertContains('Copy [web-root]/robots.txt from assets/robots.txt', $output);
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/Integration/SkipOpTest.php b/core/tests/Drupal/Tests/Component/Scaffold/Integration/SkipOpTest.php
new file mode 100644
index 0000000000..27e358e6bf
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/Integration/SkipOpTest.php
@@ -0,0 +1,36 @@
+destinationPath('[web-root]/robots.txt');
+ $options = ScaffoldOptions::create([]);
+ $sut = new SkipOp();
+ // Assert that there is no target file before we run our test.
+ $this->assertFileNotExists($destination->fullPath());
+ // Test the system under test.
+ $sut->process($destination, $fixtures->io(), $options);
+ // Assert that the target file was not created.
+ $this->assertFileNotExists($destination->fullPath());
+ // Confirm that expected output was written to our io fixture.
+ $output = $fixtures->getOutput();
+ $this->assertContains('Skip [web-root]/robots.txt: disabled', $output);
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/README.md b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/README.md
new file mode 100644
index 0000000000..0c8ab5bf80
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/README.md
@@ -0,0 +1,38 @@
+# Fixtures README
+
+These fixtures are automatically copied to a temporary directory during test
+runs. After the test run, the fixtures are automatically deleted.
+
+Set the SCAFFOLD_FIXTURE_DIR environment variable to place the fixtures in a
+specific location rather than a temporary directory. If this is done, then the
+fixtures will not be deleted after the test run. This is useful for ad-hoc
+testing.
+
+Example:
+
+$ SCAFFOLD_FIXTURE_DIR=$HOME/tmp/scaffold-fixtures composer unit
+$ cd $HOME/tmp/scaffold-fixtures
+$ cd drupal-drupal
+$ composer composer:scaffold
+
+Scaffolding files for fixtures/drupal-assets-fixture:
+ - Link [web-root]/.csslintrc from assets/.csslintrc
+ - Link [web-root]/.editorconfig from assets/.editorconfig
+ - Link [web-root]/.eslintignore from assets/.eslintignore
+ - Link [web-root]/.eslintrc.json from assets/.eslintrc.json
+ - Link [web-root]/.gitattributes from assets/.gitattributes
+ - Link [web-root]/.ht.router.php from assets/.ht.router.php
+ - Skip [web-root]/.htaccess: overridden in my/project
+ - Link [web-root]/sites/default/default.services.yml from assets/default.services.yml
+ - Skip [web-root]/sites/default/default.settings.php: overridden in fixtures/scaffold-override-fixture
+ - Link [web-root]/sites/example.settings.local.php from assets/example.settings.local.php
+ - Link [web-root]/sites/example.sites.php from assets/example.sites.php
+ - Link [web-root]/index.php from assets/index.php
+ - Skip [web-root]/robots.txt: overridden in my/project
+ - Link [web-root]/update.php from assets/update.php
+ - Link [web-root]/web.config from assets/web.config
+Scaffolding files for fixtures/scaffold-override-fixture:
+ - Link [web-root]/sites/default/default.settings.php from assets/override-settings.php
+Scaffolding files for my/project:
+ - Skip [web-root]/.htaccess: disabled
+ - Link [web-root]/robots.txt from assets/robots-default.txt
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/assets/robots-default.txt
new file mode 100644
index 0000000000..a26bf8912f
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in composer-hooks-fixture composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/composer.json.tmpl
new file mode 100644
index 0000000000..f08fa60383
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-fixture/composer.json.tmpl
@@ -0,0 +1,67 @@
+{
+ "name": "fixtures/drupal-drupal",
+ "type": "project",
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "repositories": {
+ "composer-scaffold": {
+ "type": "path",
+ "url": "__PROJECT_ROOT__",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-core-fixture": {
+ "type": "path",
+ "url": "../drupal-core-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-assets-fixture": {
+ "type": "path",
+ "url": "../drupal-assets-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "scaffold-override-fixture": {
+ "type": "path",
+ "url": "../scaffold-override-fixture",
+ "options": {
+ "symlink": true
+ }
+ }
+ },
+ "require": {
+ "drupal/core-composer-scaffold": "*",
+ "fixtures/drupal-core-fixture": "*"
+ },
+ "extra": {
+ "composer-scaffold": {
+ "allowed-packages": [
+ "fixtures/drupal-core-fixture",
+ "fixtures/scaffold-override-fixture"
+ ],
+ "locations": {
+ "web-root": "./"
+ },
+ "symlink": __SYMLINK__,
+ "file-mapping": {
+ "[web-root]/.htaccess": false,
+ "[web-root]/robots.txt": "assets/robots-default.txt"
+ }
+ },
+ "installer-paths": {
+ "core": ["type:drupal-core"],
+ "modules/contrib/{$name}": ["type:drupal-module"],
+ "modules/custom/{$name}": ["type:drupal-custom-module"],
+ "profiles/contrib/{$name}": ["type:drupal-profile"],
+ "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+ "themes/contrib/{$name}": ["type:drupal-theme"],
+ "themes/custom/{$name}": ["type:drupal-custom-theme"],
+ "libraries/{$name}": ["type:drupal-library"],
+ "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/assets/robots-default.txt
new file mode 100644
index 0000000000..a26bf8912f
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in composer-hooks-fixture composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/composer.json.tmpl
new file mode 100644
index 0000000000..2acbea22f4
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/composer-hooks-nothing-allowed-fixture/composer.json.tmpl
@@ -0,0 +1,63 @@
+{
+ "name": "fixtures/drupal-drupal",
+ "type": "project",
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "repositories": {
+ "composer-scaffold": {
+ "type": "path",
+ "url": "__PROJECT_ROOT__",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-core-fixture": {
+ "type": "path",
+ "url": "../drupal-core-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-assets-fixture": {
+ "type": "path",
+ "url": "../drupal-assets-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "scaffold-override-fixture": {
+ "type": "path",
+ "url": "../scaffold-override-fixture",
+ "options": {
+ "symlink": true
+ }
+ }
+ },
+ "require": {
+ "drupal/core-composer-scaffold": "*",
+ "fixtures/drupal-core-fixture": "*"
+ },
+ "extra": {
+ "composer-scaffold": {
+ "locations": {
+ "web-root": "./"
+ },
+ "symlink": __SYMLINK__,
+ "file-mapping": {
+ "[web-root]/.htaccess": false,
+ "[web-root]/robots.txt": "assets/robots-default.txt"
+ }
+ },
+ "installer-paths": {
+ "core": ["type:drupal-core"],
+ "modules/contrib/{$name}": ["type:drupal-module"],
+ "modules/custom/{$name}": ["type:drupal-custom-module"],
+ "profiles/contrib/{$name}": ["type:drupal-profile"],
+ "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+ "themes/contrib/{$name}": ["type:drupal-theme"],
+ "themes/custom/{$name}": ["type:drupal-custom-theme"],
+ "libraries/{$name}": ["type:drupal-library"],
+ "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.csslintrc b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.csslintrc
new file mode 100644
index 0000000000..f5bca65208
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.csslintrc
@@ -0,0 +1 @@
+# Test version of .csslintrc from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.editorconfig b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.editorconfig
new file mode 100644
index 0000000000..dcf0c98bbf
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.editorconfig
@@ -0,0 +1 @@
+# Test version of .editorconfig from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintignore b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintignore
new file mode 100644
index 0000000000..f0405c03ad
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintignore
@@ -0,0 +1 @@
+# Test version of .eslintignore from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintrc.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintrc.json
new file mode 100644
index 0000000000..6391fb0c6e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.eslintrc.json
@@ -0,0 +1 @@
+// Test version of .eslintrc.json from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.gitattributes b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.gitattributes
new file mode 100644
index 0000000000..03436baebc
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.gitattributes
@@ -0,0 +1 @@
+# Test version of .gitattributes from drupal/core.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.ht.router.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.ht.router.php
new file mode 100644
index 0000000000..0cd34ad7ad
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/assets/.ht.router.php
@@ -0,0 +1,2 @@
+
+
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/composer.json
new file mode 100644
index 0000000000..cb45ec5dc4
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-assets-fixture/composer.json
@@ -0,0 +1,24 @@
+{
+ "name": "fixtures/drupal-assets-fixture",
+ "extra": {
+ "composer-scaffold": {
+ "file-mapping": {
+ "[web-root]/.csslintrc": "assets/.csslintrc",
+ "[web-root]/.editorconfig": "assets/.editorconfig",
+ "[web-root]/.eslintignore": "assets/.eslintignore",
+ "[web-root]/.eslintrc.json": "assets/.eslintrc.json",
+ "[web-root]/.gitattributes": "assets/.gitattributes",
+ "[web-root]/.ht.router.php": "assets/.ht.router.php",
+ "[web-root]/.htaccess": "assets/.htaccess",
+ "[web-root]/sites/default/default.services.yml": "assets/default.services.yml",
+ "[web-root]/sites/default/default.settings.php": "assets/default.settings.php",
+ "[web-root]/sites/example.settings.local.php": "assets/example.settings.local.php",
+ "[web-root]/sites/example.sites.php": "assets/example.sites.php",
+ "[web-root]/index.php": "assets/index.php",
+ "[web-root]/robots.txt": "assets/robots.txt",
+ "[web-root]/update.php": "assets/update.php",
+ "[web-root]/web.config": "assets/web.config"
+ }
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/.gitignore b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/.gitignore
new file mode 100644
index 0000000000..19982ea3fd
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/.gitignore
@@ -0,0 +1,2 @@
+composer.lock
+vendor
\ No newline at end of file
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/assets/robots-default.txt
new file mode 100644
index 0000000000..c29217ef39
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-composer-drupal-project composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/composer.json.tmpl
new file mode 100644
index 0000000000..207e3baa6c
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/composer.json.tmpl
@@ -0,0 +1,68 @@
+{
+ "name": "fixtures/drupal-composer-drupal-project",
+ "type": "project",
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "repositories": {
+ "composer-scaffold": {
+ "type": "path",
+ "url": "__PROJECT_ROOT__",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-core-fixture": {
+ "type": "path",
+ "url": "../drupal-core-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-assets-fixture": {
+ "type": "path",
+ "url": "../drupal-assets-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "scaffold-override-fixture": {
+ "type": "path",
+ "url": "../scaffold-override-fixture",
+ "options": {
+ "symlink": true
+ }
+ }
+ },
+ "require": {
+ "drupal/core-composer-scaffold": "*",
+ "fixtures/drupal-core-fixture": "*",
+ "fixtures/scaffold-override-fixture": "*"
+ },
+ "extra": {
+ "composer-scaffold": {
+ "allowed-packages": [
+ "fixtures/drupal-core-fixture",
+ "fixtures/scaffold-override-fixture"
+ ],
+ "locations": {
+ "web-root": "./docroot"
+ },
+ "symlink": __SYMLINK__,
+ "file-mapping": {
+ "[web-root]/.htaccess": false,
+ "[web-root]/robots.txt": "assets/robots-default.txt"
+ }
+ },
+ "installer-paths": {
+ "docroot/core": ["type:drupal-core"],
+ "docroot/modules/contrib/{$name}": ["type:drupal-module"],
+ "docroot/modules/custom/{$name}": ["type:drupal-custom-module"],
+ "docroot/profiles/contrib/{$name}": ["type:drupal-profile"],
+ "docroot/profiles/custom/{$name}": ["type:drupal-custom-profile"],
+ "docroot/themes/contrib/{$name}": ["type:drupal-theme"],
+ "docroot/themes/custom/{$name}": ["type:drupal-custom-theme"],
+ "docroot/libraries/{$name}": ["type:drupal-library"],
+ "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/.gitignore b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/.gitignore
new file mode 100644
index 0000000000..c795b054e5
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/.gitignore
@@ -0,0 +1 @@
+build
\ No newline at end of file
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/README.md b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/README.md
new file mode 100644
index 0000000000..7e59600739
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/README.md
@@ -0,0 +1 @@
+# README
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/sites/default/README.md b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/sites/default/README.md
new file mode 100644
index 0000000000..7e59600739
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-composer-drupal-project/docroot/sites/default/README.md
@@ -0,0 +1 @@
+# README
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-core-fixture/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-core-fixture/composer.json
new file mode 100644
index 0000000000..9aa29f55bf
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-core-fixture/composer.json
@@ -0,0 +1,13 @@
+{
+ "name": "fixtures/drupal-core-fixture",
+ "require": {
+ "fixtures/drupal-assets-fixture": "*"
+ },
+ "extra": {
+ "composer-scaffold": {
+ "allowed-packages": [
+ "fixtures/drupal-assets-fixture"
+ ]
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/assets/append-to-robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/assets/append-to-robots.txt
new file mode 100644
index 0000000000..5290d32590
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/assets/append-to-robots.txt
@@ -0,0 +1,3 @@
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+# This content is appended to the bottom of the existing robots.txt fixture.
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-append-to-append composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/assets/prepend-to-robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/assets/prepend-to-robots.txt
new file mode 100644
index 0000000000..8f7550ff5e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/assets/prepend-to-robots.txt
@@ -0,0 +1,3 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-append-to-append composer.json fixture.
+# This content is prepended to the top of the existing robots.txt fixture.
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/composer.json.tmpl
new file mode 100644
index 0000000000..1919d878c6
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-append-to-append/composer.json.tmpl
@@ -0,0 +1,71 @@
+{
+ "name": "fixtures/drupal-drupal-test-append",
+ "type": "project",
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "repositories": {
+ "composer-scaffold": {
+ "type": "path",
+ "url": "__PROJECT_ROOT__",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-core-fixture": {
+ "type": "path",
+ "url": "../drupal-core-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "profile-with-append": {
+ "type": "path",
+ "url": "../profile-with-append",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-assets-fixture": {
+ "type": "path",
+ "url": "../drupal-assets-fixture",
+ "options": {
+ "symlink": true
+ }
+ }
+ },
+ "require": {
+ "drupal/core-composer-scaffold": "*",
+ "fixtures/profile-with-append": "*",
+ "fixtures/drupal-core-fixture": "*"
+ },
+ "extra": {
+ "composer-scaffold": {
+ "allowed-packages": [
+ "fixtures/drupal-core-fixture",
+ "fixtures/profile-with-append"
+ ],
+ "locations": {
+ "web-root": "./"
+ },
+ "symlink": __SYMLINK__,
+ "file-mapping": {
+ "[web-root]/.htaccess": false,
+ "[web-root]/robots.txt": {
+ "prepend": "assets/prepend-to-robots.txt",
+ "append": "assets/append-to-robots.txt"
+ }
+ }
+ },
+ "installer-paths": {
+ "core": ["type:drupal-core"],
+ "modules/contrib/{$name}": ["type:drupal-module"],
+ "modules/custom/{$name}": ["type:drupal-custom-module"],
+ "profiles/contrib/{$name}": ["type:drupal-profile"],
+ "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+ "themes/contrib/{$name}": ["type:drupal-theme"],
+ "themes/custom/{$name}": ["type:drupal-custom-theme"],
+ "libraries/{$name}": ["type:drupal-library"],
+ "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-missing-scaffold-file/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-missing-scaffold-file/composer.json.tmpl
new file mode 100644
index 0000000000..8d9cf57293
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-missing-scaffold-file/composer.json.tmpl
@@ -0,0 +1,68 @@
+{
+ "name": "fixtures/drupal-drupal-missing-scaffold-file",
+ "type": "project",
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "repositories": {
+ "composer-scaffold": {
+ "type": "path",
+ "url": "__PROJECT_ROOT__",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-core-fixture": {
+ "type": "path",
+ "url": "../drupal-core-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-assets-fixture": {
+ "type": "path",
+ "url": "../drupal-assets-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "scaffold-override-fixture": {
+ "type": "path",
+ "url": "../scaffold-override-fixture",
+ "options": {
+ "symlink": true
+ }
+ }
+ },
+ "require": {
+ "drupal/core-composer-scaffold": "*",
+ "fixtures/drupal-core-fixture": "*",
+ "fixtures/scaffold-override-fixture": "*"
+ },
+ "extra": {
+ "composer-scaffold": {
+ "allowed-packages": [
+ "fixtures/drupal-core-fixture",
+ "fixtures/scaffold-override-fixture"
+ ],
+ "locations": {
+ "web-root": "./"
+ },
+ "symlink": __SYMLINK__,
+ "file-mapping": {
+ "[web-root]/.htaccess": false,
+ "[web-root]/robots.txt": "assets/missing-robots-default.txt"
+ }
+ },
+ "installer-paths": {
+ "core": ["type:drupal-core"],
+ "modules/contrib/{$name}": ["type:drupal-module"],
+ "modules/custom/{$name}": ["type:drupal-custom-module"],
+ "profiles/contrib/{$name}": ["type:drupal-profile"],
+ "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+ "themes/contrib/{$name}": ["type:drupal-theme"],
+ "themes/custom/{$name}": ["type:drupal-custom-theme"],
+ "libraries/{$name}": ["type:drupal-library"],
+ "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/append-to-robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/append-to-robots.txt
new file mode 100644
index 0000000000..8f05fc3b3a
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/append-to-robots.txt
@@ -0,0 +1,3 @@
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+# This content is appended to the bottom of the existing robots.txt fixture.
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/prepend-to-robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/prepend-to-robots.txt
new file mode 100644
index 0000000000..995c204a6e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/assets/prepend-to-robots.txt
@@ -0,0 +1,3 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
+# This content is prepended to the top of the existing robots.txt fixture.
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/composer.json.tmpl
new file mode 100644
index 0000000000..9e6c579692
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-append/composer.json.tmpl
@@ -0,0 +1,62 @@
+{
+ "name": "fixtures/drupal-drupal-test-append",
+ "type": "project",
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "repositories": {
+ "composer-scaffold": {
+ "type": "path",
+ "url": "__PROJECT_ROOT__",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-core-fixture": {
+ "type": "path",
+ "url": "../drupal-core-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-assets-fixture": {
+ "type": "path",
+ "url": "../drupal-assets-fixture",
+ "options": {
+ "symlink": true
+ }
+ }
+ },
+ "require": {
+ "drupal/core-composer-scaffold": "*",
+ "fixtures/drupal-core-fixture": "*"
+ },
+ "extra": {
+ "composer-scaffold": {
+ "allowed-packages": [
+ "fixtures/drupal-core-fixture"
+ ],
+ "locations": {
+ "web-root": "./"
+ },
+ "symlink": __SYMLINK__,
+ "file-mapping": {
+ "[web-root]/.htaccess": false,
+ "[web-root]/robots.txt": {
+ "prepend": "assets/prepend-to-robots.txt",
+ "append": "assets/append-to-robots.txt"
+ }
+ }
+ },
+ "installer-paths": {
+ "core": ["type:drupal-core"],
+ "modules/contrib/{$name}": ["type:drupal-module"],
+ "modules/custom/{$name}": ["type:drupal-custom-module"],
+ "profiles/contrib/{$name}": ["type:drupal-profile"],
+ "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+ "themes/contrib/{$name}": ["type:drupal-theme"],
+ "themes/custom/{$name}": ["type:drupal-custom-theme"],
+ "libraries/{$name}": ["type:drupal-library"],
+ "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/replacement.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/replacement.txt
new file mode 100644
index 0000000000..4e23d0c860
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/replacement.txt
@@ -0,0 +1 @@
+# File from assets that replaces file in web root.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/robots-default.txt
new file mode 100644
index 0000000000..28c7646d81
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-overwrite composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/composer.json.tmpl
new file mode 100644
index 0000000000..d16205c612
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/composer.json.tmpl
@@ -0,0 +1,80 @@
+{
+ "name": "fixtures/drupal-drupal-test-overwrite",
+ "type": "project",
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "repositories": {
+ "composer-scaffold": {
+ "type": "path",
+ "url": "__PROJECT_ROOT__",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-core-fixture": {
+ "type": "path",
+ "url": "../drupal-core-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-assets-fixture": {
+ "type": "path",
+ "url": "../drupal-assets-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "scaffold-override-fixture": {
+ "type": "path",
+ "url": "../scaffold-override-fixture",
+ "options": {
+ "symlink": true
+ }
+ }
+ },
+ "require": {
+ "drupal/core-composer-scaffold": "*",
+ "fixtures/drupal-core-fixture": "*",
+ "fixtures/scaffold-override-fixture": "*"
+ },
+ "extra": {
+ "composer-scaffold": {
+ "allowed-packages": [
+ "fixtures/drupal-core-fixture",
+ "fixtures/scaffold-override-fixture"
+ ],
+ "locations": {
+ "web-root": "./"
+ },
+ "symlink": __SYMLINK__,
+ "file-mapping": {
+ "[web-root]/.htaccess": false,
+ "[web-root]/robots.txt": "assets/robots-default.txt",
+ "make-me.txt": {
+ "path": "assets/replacement.txt",
+ "overwrite": false
+ },
+ "keep-me.txt": {
+ "path": "assets/replacement.txt",
+ "overwrite": false
+ },
+ "replace-me.txt": {
+ "path": "assets/replacement.txt",
+ "overwrite": true
+ }
+ }
+ },
+ "installer-paths": {
+ "core": ["type:drupal-core"],
+ "modules/contrib/{$name}": ["type:drupal-module"],
+ "modules/custom/{$name}": ["type:drupal-custom-module"],
+ "profiles/contrib/{$name}": ["type:drupal-profile"],
+ "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+ "themes/contrib/{$name}": ["type:drupal-theme"],
+ "themes/custom/{$name}": ["type:drupal-custom-theme"],
+ "libraries/{$name}": ["type:drupal-library"],
+ "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/keep-me.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/keep-me.txt
new file mode 100644
index 0000000000..772a59531a
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/keep-me.txt
@@ -0,0 +1 @@
+# File in drupal-drupal-test-overwrite that is not replaced by a scaffold file.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/replace-me.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/replace-me.txt
new file mode 100644
index 0000000000..4147b02214
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal-test-overwrite/replace-me.txt
@@ -0,0 +1 @@
+# File in drupal-drupal-test-overwrite that is replaced by a scaffold file.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/assets/robots-default.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/assets/robots-default.txt
new file mode 100644
index 0000000000..6eb30e86aa
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/assets/robots-default.txt
@@ -0,0 +1 @@
+# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/composer.json.tmpl
new file mode 100644
index 0000000000..a8641ef7a2
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-drupal/composer.json.tmpl
@@ -0,0 +1,74 @@
+{
+ "name": "fixtures/drupal-drupal",
+ "type": "project",
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "repositories": {
+ "composer-scaffold": {
+ "type": "path",
+ "url": "__PROJECT_ROOT__",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-core-fixture": {
+ "type": "path",
+ "url": "../drupal-core-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-assets-fixture": {
+ "type": "path",
+ "url": "../drupal-assets-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "scaffold-override-fixture": {
+ "type": "path",
+ "url": "../scaffold-override-fixture",
+ "options": {
+ "symlink": true
+ }
+ }
+ },
+ "require": {
+ "drupal/core-composer-scaffold": "*",
+ "fixtures/drupal-core-fixture": "*",
+ "fixtures/scaffold-override-fixture": "*"
+ },
+ "extra": {
+ "composer-scaffold": {
+ "allowed-packages": [
+ "fixtures/drupal-core-fixture",
+ "fixtures/scaffold-override-fixture"
+ ],
+ "locations": {
+ "web-root": "./"
+ },
+ "gitignore": false,
+ "overwrite": true,
+ "symlink": __SYMLINK__,
+ "file-mapping": {
+ "[web-root]/.htaccess": false,
+ "[web-root]/robots.txt": {
+ "mode": "replace",
+ "path": "assets/robots-default.txt",
+ "overwrite": true
+ }
+ }
+ },
+ "installer-paths": {
+ "core": ["type:drupal-core"],
+ "modules/contrib/{$name}": ["type:drupal-module"],
+ "modules/custom/{$name}": ["type:drupal-custom-module"],
+ "profiles/contrib/{$name}": ["type:drupal-profile"],
+ "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+ "themes/contrib/{$name}": ["type:drupal-theme"],
+ "themes/custom/{$name}": ["type:drupal-custom-theme"],
+ "libraries/{$name}": ["type:drupal-library"],
+ "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/assets/profile.default.services.yml b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/assets/profile.default.services.yml
new file mode 100644
index 0000000000..2a35c02dba
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/assets/profile.default.services.yml
@@ -0,0 +1,4 @@
+# default.services.yml fixture scaffolded from "file-mappings" in drupal-project composer.json fixture.
+# Add a dummy key until YamlPecl can validate an empty YAML file:
+# https://www.drupal.org/project/drupal/issues/3003300
+foo: bar
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/composer.json.tmpl
new file mode 100644
index 0000000000..b3e4b76640
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/drupal-profile/composer.json.tmpl
@@ -0,0 +1,10 @@
+{
+ "name": "fixtures/drupal-profile",
+ "extra": {
+ "composer-scaffold": {
+ "file-mapping": {
+ "[web-root]/.htaccess": false
+ }
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture-allowing-core/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture-allowing-core/composer.json
new file mode 100644
index 0000000000..66e96bf38b
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture-allowing-core/composer.json
@@ -0,0 +1,13 @@
+{
+ "name": "fixtures/empty-fixture-allowing-core",
+ "extra": {
+ "composer-scaffold": {
+ "allowed-packages": [
+ "fixtures/drupal-core-fixture"
+ ],
+ "locations": {
+ "web-root": "./"
+ }
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture/composer.json
new file mode 100644
index 0000000000..7eb36a74cb
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/empty-fixture/composer.json
@@ -0,0 +1,3 @@
+{
+ "name": "fixtures/empty-fixture"
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/packages.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/packages.json
new file mode 100644
index 0000000000..de4226972a
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/packages.json
@@ -0,0 +1,14 @@
+{
+ "packages": {
+ "fixtures/drupal-drupal": {
+ "dev-master": {
+ "name": "fixtures/drupal-drupal",
+ "version": "1.0.0",
+ "dist": {
+ "url": "./drupal-drupal",
+ "type": "path"
+ }
+ }
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/profile-with-append/assets/append-to-robots.txt b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/profile-with-append/assets/append-to-robots.txt
new file mode 100644
index 0000000000..ab2eb43a74
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/profile-with-append/assets/append-to-robots.txt
@@ -0,0 +1,3 @@
+# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+# This content is appended to the bottom of the existing robots.txt fixture.
+# robots.txt fixture scaffolded from "file-mappings" in profile-with-append composer.json fixture.
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/profile-with-append/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/profile-with-append/composer.json.tmpl
new file mode 100644
index 0000000000..d67135fcac
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/profile-with-append/composer.json.tmpl
@@ -0,0 +1,12 @@
+{
+ "name": "fixtures/profile-with-append",
+ "extra": {
+ "composer-scaffold": {
+ "file-mapping": {
+ "[web-root]/robots.txt": {
+ "append": "assets/append-to-robots.txt"
+ }
+ }
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-allowing-empty-fixture/composer.json.tmpl b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-allowing-empty-fixture/composer.json.tmpl
new file mode 100644
index 0000000000..254492a6e9
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-allowing-empty-fixture/composer.json.tmpl
@@ -0,0 +1,77 @@
+{
+ "name": "fixtures/project-allowing-empty-fixture",
+ "type": "project",
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "repositories": {
+ "composer-scaffold": {
+ "type": "path",
+ "url": "__PROJECT_ROOT__",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-core-fixture": {
+ "type": "path",
+ "url": "../drupal-core-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "drupal-assets-fixture": {
+ "type": "path",
+ "url": "../drupal-assets-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "empty-fixture": {
+ "type": "path",
+ "url": "../empty-fixture",
+ "options": {
+ "symlink": true
+ }
+ },
+ "scaffold-override-fixture": {
+ "type": "path",
+ "url": "../scaffold-override-fixture",
+ "options": {
+ "symlink": true
+ }
+ }
+ },
+ "require": {
+ "drupal/core-composer-scaffold": "*",
+ "fixtures/drupal-core-fixture": "*",
+ "fixtures/empty-fixture": "*",
+ "fixtures/scaffold-override-fixture": "*"
+ },
+ "extra": {
+ "composer-scaffold": {
+ "allowed-packages": [
+ "fixtures/drupal-core-fixture",
+ "fixtures/empty-fixture",
+ "fixtures/scaffold-override-fixture"
+ ],
+ "locations": {
+ "web-root": "./"
+ },
+ "gitignore": false,
+ "symlink": __SYMLINK__,
+ "file-mapping": {
+ "[web-root]/.htaccess": false
+ }
+ },
+ "installer-paths": {
+ "core": ["type:drupal-core"],
+ "modules/contrib/{$name}": ["type:drupal-module"],
+ "modules/custom/{$name}": ["type:drupal-custom-module"],
+ "profiles/contrib/{$name}": ["type:drupal-profile"],
+ "profiles/custom/{$name}": ["type:drupal-custom-profile"],
+ "themes/contrib/{$name}": ["type:drupal-theme"],
+ "themes/custom/{$name}": ["type:drupal-custom-theme"],
+ "libraries/{$name}": ["type:drupal-library"],
+ "drush/Commands/contrib/{$name}": ["type:drupal-drush"]
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-empty-scaffold-path/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-empty-scaffold-path/composer.json
new file mode 100644
index 0000000000..e74df96ff0
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-empty-scaffold-path/composer.json
@@ -0,0 +1,15 @@
+{
+ "name": "fixtures/project-with-empty-scaffold-path",
+ "extra": {
+ "composer-scaffold": {
+ "locations": {
+ "web-root": "./"
+ },
+ "file-mapping": {
+ "[web-root]/my-error": {
+ "path": ""
+ }
+ }
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/assets/README.md b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/assets/README.md
new file mode 100644
index 0000000000..7e59600739
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/assets/README.md
@@ -0,0 +1 @@
+# README
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/composer.json b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/composer.json
new file mode 100644
index 0000000000..5cebfd4b57
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/project-with-illegal-dir-scaffold/composer.json
@@ -0,0 +1,15 @@
+{
+ "name": "fixtures/project-with-illegal-dir-scaffold",
+ "extra": {
+ "composer-scaffold": {
+ "locations": {
+ "web-root": "./"
+ },
+ "file-mapping": {
+ "[web-root]/assets": {
+ "path": "assets"
+ }
+ }
+ }
+ }
+}
diff --git a/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/assets/override-settings.php b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/assets/override-settings.php
new file mode 100644
index 0000000000..064ed7e3f5
--- /dev/null
+++ b/core/tests/Drupal/Tests/Component/Scaffold/fixtures/scaffold-override-fixture/assets/override-settings.php
@@ -0,0 +1,6 @@
+