Commit c1346b67 authored by larowlan's avatar larowlan

Issue #3085697 by greg.1.anderson, Mixologic, mbaynton, larowlan: Allow...

Issue #3085697 by greg.1.anderson, Mixologic, mbaynton, larowlan: Allow scaffold plugin to append to non-scaffolded files
parent f5040c6c
<?php
namespace Drupal\Composer\Plugin\Scaffold\Operations;
use Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath;
/**
* Provides default behaviors for operations.
*/
abstract class AbstractOperation implements OperationInterface {
/**
* {@inheritdoc}
*/
public function combineWithConjunctionTarget(OperationInterface $conjunction_target) {
return $this;
}
/**
* {@inheritdoc}
*/
public function missingConjunctionTarget(ScaffoldFilePath $destination) {
return $this;
}
}
......@@ -9,7 +9,7 @@
/**
* Scaffold operation to add to the beginning and/or end of a scaffold file.
*/
class AppendOp implements OperationInterface, ConjoinableInterface {
class AppendOp extends AbstractOperation {
/**
* Identifies Append operations.
......@@ -30,6 +30,23 @@ class AppendOp implements OperationInterface, ConjoinableInterface {
*/
protected $append;
/**
* Path to the default data to use when appending to an empty file.
*
* @var \Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath
*/
protected $default;
/**
* An indicator of whether the file we are appending to is managed or not.
*/
protected $managed;
/**
* An indicator of whether we are allowed to append to a non-scaffolded file.
*/
protected $forceAppend;
/**
* Constructs an AppendOp.
*
......@@ -37,10 +54,17 @@ class AppendOp implements OperationInterface, ConjoinableInterface {
* The relative path to the prepend file.
* @param \Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath $append_path
* The relative path to the append file.
* @param bool $force_append
* TRUE if is okay to append to a file that was not scaffolded.
* @param \Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath $default_path
* The relative path to the default data.
*/
public function __construct(ScaffoldFilePath $prepend_path = NULL, ScaffoldFilePath $append_path = NULL) {
public function __construct(ScaffoldFilePath $prepend_path = NULL, ScaffoldFilePath $append_path = NULL, $force_append = FALSE, ScaffoldFilePath $default_path = NULL) {
$this->forceAppend = $force_append;
$this->prepend = $prepend_path;
$this->append = $append_path;
$this->default = $default_path;
$this->managed = TRUE;
}
/**
......@@ -48,11 +72,25 @@ public function __construct(ScaffoldFilePath $prepend_path = NULL, ScaffoldFileP
*/
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]."));
// This is just a sanity check; the OperationFactory has in theory already
// accounted for this, and will return a SkipOp with a warning message.
if (!file_exists($destination_path) && empty($this->default)) {
throw new \RuntimeException($destination->getInterpolator()->interpolate("Cannot append/prepend because no prior package provided a scaffold file at [dest-rel-path]."));
}
$interpolator = $destination->getInterpolator();
// Be extra-noisy of creating a new file or appending to a non-scaffold
// file. Note that if the file already has the append contents, then the
// OperationFactory will make a SkipOp instead, and we will not get here.
if (!$this->managed) {
$message = ' - <info>NOTICE</info> Modifying existing file at <info>[dest-rel-path]</info>.';
if (!file_exists($destination_path)) {
$message = ' - <info>NOTICE</info> Creating a new file at <info>[dest-rel-path]</info>.';
}
$message .= ' Examine the contents and ensure that it came out correctly.';
$io->write($interpolator->interpolate($message));
}
// Fetch the prepend contents, if provided.
$prepend_contents = '';
if (!empty($this->prepend)) {
......@@ -67,18 +105,82 @@ public function process(ScaffoldFilePath $destination, IOInterface $io, Scaffold
$append_contents = "\n" . file_get_contents($this->append->fullPath());
$io->write($interpolator->interpolate(" - Append to <info>[dest-rel-path]</info> from <info>[append-rel-path]</info>"));
}
// We typically should always have content if we get here; the
// OperationFactory should create a SkipOp instead of an AppendOp if there
// is no append / prepend content. The edge case is if there is content
// that is all 'trim'ed away. Then we get a message that we are appending,
// although nothing will in fact actually happen.
if (!empty(trim($prepend_contents)) || !empty(trim($append_contents))) {
// None of our asset files are very large, so we will load each one into
// memory for processing.
$original_contents = file_get_contents($destination_path);
$original_contents = file_get_contents(file_exists($destination_path) ? $destination_path : $this->default->fullPath());
// Write the appended and prepended contents back to the file.
$altered_contents = $prepend_contents . $original_contents . $append_contents;
file_put_contents($destination_path, $altered_contents);
}
else {
$io->write($interpolator->interpolate(" - Keep <info>[dest-rel-path]</info> unchanged: no content to prepend / append was provided."));
// Return a ScaffoldResult with knowledge of whether this file is managed.
return new ScaffoldResult($destination, $this->managed);
}
/**
* {@inheritdoc}
*/
public function combineWithConjunctionTarget(OperationInterface $conjunction_target) {
return new ConjunctionOp($conjunction_target, $this);
}
/**
* {@inheritdoc}
*/
public function missingConjunctionTarget(ScaffoldFilePath $destination) {
// If there is no conjunction target (the destination is not scaffolded),
// then any append we do will be to an unmanaged file.
$this->managed = FALSE;
// Default: do not allow an append over a file that was not scaffolded.
if (!$this->forceAppend) {
$message = " - Skip <info>[dest-rel-path]</info>: cannot append to a path that was not scaffolded unless 'force-append' property is set.";
return new SkipOp($message);
}
// If the target file does not exist, then we will allow the append to
// happen if we have default data to provide for it.
if (!file_exists($destination->fullPath())) {
if (!empty($this->default)) {
return $this;
}
$message = " - Skip <info>[dest-rel-path]</info>: no file exists at the target path, and no default data provided.";
return new SkipOp($message);
}
// If the target file DOES exist, and it already contains the append/prepend
// data, then we will skip the operation.
$existingData = file_get_contents($destination->fullPath());
if ($this->existingFileHasData($existingData, $this->append) || $this->existingFileHasData($existingData, $this->prepend)) {
$message = " - Skip <info>[dest-rel-path]</info>: the file already has the append/prepend data.";
return new SkipOp($message);
}
return new ScaffoldResult($destination, TRUE);
return $this;
}
/**
* Check to see if the append/prepend data has already been applied.
* @param string $contents
* The contents of the target file.
* @param \Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath $data_path
* The path to the data to append or prepend
* @return bool
* 'TRUE' if the append/prepend data already exists in contents.
*/
protected function existingFileHasData($contents, $data_path) {
if (empty($data_path)) {
return FALSE;
}
$data = file_get_contents($data_path->fullPath());
return strpos($contents, $data) !== FALSE;
}
}
<?php
namespace Drupal\Composer\Plugin\Scaffold\Operations;
/**
* Marker interface indicating that operation is conjoinable.
*
* A conjoinable operation is one that runs in addition to any previous
* operation defined at the same destination path. Operations that are
* not conjoinable simply replace anything at the same destination path.
*/
interface ConjoinableInterface {
}
......@@ -9,7 +9,7 @@
/**
* Joins two operations on the same file into a single operation.
*/
class ConjunctionOp implements OperationInterface {
class ConjunctionOp extends AbstractOperation {
/**
* The first operation.
......
......@@ -12,6 +12,8 @@ class OperationData {
const OVERWRITE = 'overwrite';
const PREPEND = 'prepend';
const APPEND = 'append';
const DEFAULT = 'default';
const FORCE_APPEND = 'force-append';
/**
* The parameter data.
......@@ -85,7 +87,20 @@ public function path() {
* Returns true if overwrite mode was selected.
*/
public function overwrite() {
return isset($this->data[self::OVERWRITE]) ? $this->data[self::OVERWRITE] : TRUE;
return !empty($this->data[self::OVERWRITE]);
}
/**
* Determines whether 'force-append' has been set.
*
* @return bool
* Returns true if 'force-append' mode was selected.
*/
public function forceAppend() {
if ($this->hasDefault()) {
return TRUE;
}
return !empty($this->data[self::FORCE_APPEND]);
}
/**
......@@ -128,6 +143,26 @@ public function append() {
return $this->data[self::APPEND];
}
/**
* Checks if default path exists.
*
* @return bool
* Returns true if there is default data available.
*/
public function hasDefault() {
return isset($this->data[self::DEFAULT]);
}
/**
* Gets default path.
*
* @return string
* Path to default data
*/
public function default() {
return $this->data[self::DEFAULT];
}
/**
* Normalizes metadata by converting literal values into arrays.
*
......@@ -141,9 +176,32 @@ public function append() {
* The metadata for this operation object, which varies by operation type.
*
* @return array
* Normalized scaffold metadata.
* Normalized scaffold metadata with default values.
*/
protected function normalizeScaffoldMetadata($destination, $value) {
$defaultScaffoldMetadata = [
self::MODE => ReplaceOp::ID,
self::PREPEND => NULL,
self::APPEND => NULL,
self::DEFAULT => NULL,
self::OVERWRITE => TRUE,
];
return $this->convertScaffoldMetadata($destination, $value) + $defaultScaffoldMetadata;
}
/**
* Performs the conversion-to-array step in normalizeScaffoldMetadata.
*
* @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 convertScaffoldMetadata($destination, $value) {
if (is_bool($value)) {
if (!$value) {
return [self::MODE => SkipOp::ID];
......@@ -161,10 +219,6 @@ protected function normalizeScaffoldMetadata($destination, $value) {
if (!isset($value[self::MODE]) && (isset($value[self::APPEND]) || isset($value[self::PREPEND]))) {
$value[self::MODE] = AppendOp::ID;
}
// If there is no 'mode', then the default is 'replace'.
if (!isset($value[self::MODE])) {
$value[self::MODE] = ReplaceOp::ID;
}
return $value;
}
......
......@@ -99,14 +99,38 @@ protected function createAppendOp(PackageInterface $package, OperationData $oper
$package_path = $this->getPackagePath($package);
$prepend_source_file = NULL;
$append_source_file = NULL;
$default_data_file = NULL;
if ($operation_data->hasPrepend()) {
$prepend_source_file = ScaffoldFilePath::sourcePath($package_name, $package_path, $operation_data->destination(), $operation_data->prepend());
}
if ($operation_data->hasAppend()) {
$append_source_file = ScaffoldFilePath::sourcePath($package_name, $package_path, $operation_data->destination(), $operation_data->append());
}
$op = new AppendOp($prepend_source_file, $append_source_file);
return $op;
if ($operation_data->hasDefault()) {
$default_data_file = ScaffoldFilePath::sourcePath($package_name, $package_path, $operation_data->destination(), $operation_data->default());
}
if (!$this->hasContent($prepend_source_file) && !$this->hasContent($append_source_file)) {
$message = ' - Keep <info>[dest-rel-path]</info> unchanged: no content to prepend / append was provided.';
return new SkipOp($message);
}
return new AppendOp($prepend_source_file, $append_source_file, $operation_data->forceAppend(), $default_data_file);
}
/**
* Checks to see if the specified scaffold file exists and has content.
*
* @param Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath $file
* Scaffold file to check.
* @return bool
* True if the file exists and has content.
*/
protected function hasContent(ScaffoldFilePath $file = NULL) {
if (!$file) {
return FALSE;
}
$path = $file->fullPath();
return is_file($path) && (filesize($path) > 0);
}
/**
......
......@@ -26,4 +26,31 @@ interface OperationInterface {
*/
public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options);
/**
* Determines what to do if operation is used with a previous operation.
*
* Default behavior is to scaffold this operation at the specified
* destination, ignoring whatever was there before.
*
* @param OperationInterface $conjunction_target
* Existing file at the destination path that we should combine with.
*
* @return OperationInterface
* The op to use at this destination.
*/
public function combineWithConjunctionTarget(OperationInterface $conjunction_target);
/**
* Determines what to do if operation is used without a previous operation.
*
* Default behavior is to scaffold this operation at the specified
* destination. Most operations overwrite rather than modify existing files,
* and therefore do not need to do anything special when there is no existing
* file.
*
* @return OperationInterface
* The op to use at this destination.
*/
public function missingConjunctionTarget(ScaffoldFilePath $destination);
}
......@@ -10,7 +10,7 @@
/**
* Scaffold operation to copy or symlink from source to destination.
*/
class ReplaceOp implements OperationInterface {
class ReplaceOp extends AbstractOperation {
/**
* Identifies Replace operations.
......
......@@ -42,6 +42,7 @@ public function __construct(array $file_mappings, Interpolator $location_replace
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
......@@ -50,13 +51,20 @@ public function __construct(array $file_mappings, Interpolator $location_replace
// path.
if (isset($scaffoldFiles[$destination_rel_path])) {
$previous_scaffold_file = $scaffoldFiles[$destination_rel_path];
if ($op instanceof ConjoinableInterface) {
$op = new ConjunctionOp($previous_scaffold_file->op(), $op);
}
$op = $op->combineWithConjunctionTarget($previous_scaffold_file->op());
// Remove the previous op so we only touch the destination once.
$message = " - Skip <info>[dest-rel-path]</info>: overridden in <comment>{$package_name}</comment>";
$this->scaffoldFilesByProject[$previous_scaffold_file->packageName()][$destination_rel_path] = new ScaffoldFileInfo($destination, new SkipOp($message));
}
// If there is NOT already a scaffolding operation happening at this
// path, but the operation is a ConjunctionOp, then we need to check
// to see if there is a strategy for non-conjunction use.
else {
$op = $op->missingConjunctionTarget($destination);
}
// Combine the scaffold operation with the destination and record it.
$scaffold_file = new ScaffoldFileInfo($destination, $op);
$scaffoldFiles[$destination_rel_path] = $scaffold_file;
$this->scaffoldFilesByProject[$package_name][$destination_rel_path] = $scaffold_file;
......
......@@ -9,7 +9,7 @@
/**
* Scaffold operation to skip a scaffold file (do nothing).
*/
class SkipOp implements OperationInterface {
class SkipOp extends AbstractOperation {
/**
* Identifies Skip operations.
......
......@@ -302,6 +302,30 @@ path, and it is not possible for multiple entries to have the same key. If
"prepend" were a separate mode, then it would not be possible to both prepend
and append to the same file.
By default, append operations may only be applied to files that were scaffolded
by a previously evaluated project. If the `force-append` attribute is added to
an `append` operation, though, then the append will be made to non-scaffolded
files if and only if the append text does not already appear in the file. When
using this mode, it is also possible to provide default contents to use in the
event that the destination file is entirely missing.
The example below demonstrates scaffolding a settings-custom.php file, and
including it from the existing `settings.php` file.
```
"file-mapping": {
"[web-root]/sites/default/settings-custom.php": "assets/settings-custom.php",
"[web-root]/sites/default/settings.php": {
"append": "assets/include-settings-custom.txt",
"force-append": true,
"default": "assets/initial-default-settings.txt"
}
}
```
Note that the example above still works if used with a project that scaffolds
the settings.php file.
### gitignore
The `gitignore` configuration setting controls whether or not this plugin will
......
......@@ -24,4 +24,21 @@ protected function assertScaffoldedFile($path, $is_link, $contents_contains) {
$this->assertSame($is_link, is_link($path));
}
/**
* Asserts that a file does not exist or exists and does not contain a value.
*
* @param string $path
* The path to check exists.
* @param string $contents_not_contains
* A string that is expected should NOT occur in the file contents.
*/
protected function assertScaffoldedFileDoesNotContain($path, $contents_not_contains) {
// If the file does not exist at all, we'll count that as a pass.
if (!file_exists($path)) {
return;
}
$contents = file_get_contents($path);
$this->assertNotContains($contents_not_contains, $contents, basename($path) . ' contains unexpected contents:');
}
}
......@@ -159,6 +159,27 @@ public function testUnmanagedGitIgnoreWhenDisabled() {
$this->assertFileNotExists($sut . '/docroot/sites/default/.gitignore');
}
/**
* Test appending to an unmanaged file, and confirm it is not .gitignored.
*
* If we append to an unmanaged (not scaffolded) file, and we are managing
* .gitignore files, then we expect that the unmanaged file should not be
* added to the .gitignore file, because unmanaged files should be committed.
*/
public function testAppendToEmptySettingsIsUnmanaged() {
$sut = $this->createSutWithGit('drupal-drupal-append-settings');
$this->assertFileNotExists($sut . '/autoload.php');
$this->assertFileNotExists($sut . '/index.php');
$this->assertFileNotExists($sut . '/sites/.gitignore');
// Run the scaffold command.
$this->fixtures->runScaffold($sut);
$this->assertFileExists($sut . '/autoload.php');
$this->assertFileExists($sut . '/index.php');
$this->assertScaffoldedFile($sut . '/sites/.gitignore', FALSE, 'example.sites.php');
$this->assertScaffoldedFileDoesNotContain($sut . '/sites/.gitignore', 'settings.php');
}
/**
* Tests scaffold command disables .gitignore management when git not present.
*
......
......@@ -260,7 +260,20 @@ public function testDrupalDrupalFileWasReplaced() {
public function scaffoldAppendTestValues() {
return array_merge(
$this->scaffoldAppendTestValuesToPermute(FALSE),
$this->scaffoldAppendTestValuesToPermute(TRUE)
$this->scaffoldAppendTestValuesToPermute(TRUE),
[
[
'drupal-drupal-append-settings',
FALSE,
'sites/default/settings.php',
'<?php
// Default settings.php contents
include __DIR__ . "/settings-custom-additions.php";',
'NOTICE Creating a new file at [web-root]/sites/default/settings.php. Examine the contents and ensure that it came out correctly.',
],
]
);
}
......@@ -275,6 +288,7 @@ protected function scaffoldAppendTestValuesToPermute($is_link) {
[
'drupal-drupal-test-append',
$is_link,
'robots.txt',
'# 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.
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
......@@ -285,11 +299,13 @@ protected function scaffoldAppendTestValuesToPermute($is_link) {
# 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.
',
'Prepend to [web-root]/robots.txt from assets/prepend-to-robots.txt',
],
[
'drupal-drupal-append-to-append',
$is_link,
'robots.txt',
'# 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.
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
......@@ -303,6 +319,7 @@ protected function scaffoldAppendTestValuesToPermute($is_link) {
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
# 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.',
'Append to [web-root]/robots.txt from assets/append-to-robots.txt',
],
];
}
......@@ -315,15 +332,20 @@ protected function scaffoldAppendTestValuesToPermute($is_link) {
* core/tests/Drupal/Tests/Component/Scaffold/fixtures.
* @param bool $is_link
* Whether or not symlinking should be used.
* @param string $robots_txt_contents
* Regular expression matching expectations for robots.txt.
* @param string $scaffold_file_path
* Relative path to the scaffold file target we are testing.
* @param string $scaffold_file_contents
* A string expected to be contained inside the scaffold file we are testing.
* @param string $scaffoldOutputContains
* A string expected to be contained in the scaffold command output.
*
* @dataProvider scaffoldAppendTestValues
*/
public function testDrupalDrupalFileWasAppended($fixture_name, $is_link, $robots_txt_contents) {
public function testDrupalDrupalFileWasAppended($fixture_name, $is_link, $scaffold_file_path, $scaffold_file_contents, $scaffoldOutputContains) {
$result = $this->scaffoldSut($fixture_name, $is_link, FALSE);
$this->assertContains($scaffoldOutputContains, $result->scaffoldOutput());
$this->assertScaffoldedFile($result->docroot() . '/robots.txt', FALSE, $robots_txt_contents);
$this->assertScaffoldedFile($result->docroot() . '/' . $scaffold_file_path, FALSE, $scaffold_file_contents);
$this->assertCommonDrupalAssetsWereScaffolded($result->docroot(), $is_link);
$this->assertAutoloadFileCorrect($result->docroot());
}
......
{
"name": "fixtures/drupal-drupal-append-settings",
"type": "project",
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": {
"packagist.org": false,
"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": {
"drupal-scaffold": {
"allowed-packages": [
"fixtures/drupal-core-fixture"
],
"gitignore": true,
"locations": {
"web-root": "./"
},
"symlink": __SYMLINK__,
"file-mapping": {
"[web-root]/.htaccess": false,
"[web-root]/sites/default/settings.php": {
"default": "assets/default-settings.txt",
"append": "assets/append-to-settings.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"]
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment