discovery = $discovery; $this->target = ''; } /** * @return bool */ public function isSimulate() { return $this->simulate; } /** * @param bool $simulate */ public function setSimulate($simulate) { $this->simulate = $simulate; } /** * @param string $target * A directory to write to. If not provided, writes go into same dir as the corresponding legacy file. */ public function setTargetDir($target) { $this->target = $target; } public function convertOnce() { if ($this->converted) { return; } return $this->convert(); } public function convert() { $this->converted = true; $legacyFiles = $this->discovery->findAllLegacyAliasFiles(); if (!$this->checkAnyNeedsConversion($legacyFiles)) { return []; } // We reconvert all legacy files together, because the aliases // in the legacy files might be written into multiple different .yml // files, depending on the naming conventions followed. $convertedFiles = $this->convertAll($legacyFiles); $this->writeAll($convertedFiles); return $convertedFiles; } protected function checkAnyNeedsConversion($legacyFiles) { foreach ($legacyFiles as $legacyFile) { $convertedFile = $this->determineConvertedFilename($legacyFile); if ($this->checkNeedsConversion($legacyFile, $convertedFile)) { return true; } } return false; } protected function convertAll($legacyFiles) { $result = []; foreach ($legacyFiles as $legacyFile) { $convertedFile = $this->determineConvertedFilename($legacyFile); $conversionResult = $this->convertLegacyFile($legacyFile); $result = static::arrayMergeRecursiveDistinct($result, $conversionResult); // If the conversion did not generate a similarly-named .yml file, then // make sure that one is created simply to record the mod date. if (!isset($result[$convertedFile])) { $result[$convertedFile] = []; } } return $result; } protected function writeAll($convertedFiles) { foreach ($convertedFiles as $path => $data) { $contents = $this->getContents($path, $data); // Write the converted file to the target directory // if a target directory was set. if (!empty($this->target)) { $path = $this->target . '/' . basename($path); } $this->writeOne($path, $contents); } } protected function getContents($path, $data) { if (!empty($data)) { $indent = 2; return Yaml::dump($data, PHP_INT_MAX, $indent, false, true); } $recoverSource = $this->recoverLegacyFile($path); if (!$recoverSource) { $recoverSource = 'the source alias file'; } $contents = <<checksumPath($path); if ($this->safeToWrite($path, $contents, $checksumPath)) { file_put_contents($path, $contents); $this->saveChecksum($checksumPath, $path, $contents); } } /** * Without any safeguards, the conversion process could be very * dangerous to users who modify their converted alias files (as we * would encourage them to do, if the goal is to convert!). * * This method determines whether it is safe to write to the converted * alias file at the specified path. If the user has modified the target * file, then we will not overwrite it. */ protected function safeToWrite($path, $contents, $checksumPath) { // Bail if simulate mode is enabled. if ($this->isSimulate()) { return true; } // If the target file does not exist, it is always safe to write. if (!file_exists($path)) { return true; } // If the user deletes the checksum file, then we will never // overwrite the file again. This also covers potential collisions, // where the user might not realize that a legacy alias file // would write to a new site.yml file they created manually. if (!file_exists($checksumPath)) { return false; } // Read the data that exists at the target path, and calculate // the checksum of what exists there. $previousContents = file_get_contents($path); $previousChecksum = $this->calculateChecksum($previousContents); $previousWrittenChecksum = $this->readChecksum($checksumPath); // If the checksum of what we wrote before is the same as // the checksum we cached in the checksum file, then there has // been no user modification of this file, and it is safe to // overwrite it. return $previousChecksum == $previousWrittenChecksum; } public function saveChecksum($checksumPath, $path, $contents) { $name = basename($path); $comment = <<calculateChecksum($contents); @mkdir(dirname($checksumPath)); file_put_contents($checksumPath, "{$comment}\n{$checksum}"); } protected function readChecksum($checksumPath) { $checksumContents = file_get_contents($checksumPath); $checksumContents = preg_replace('/^#.*/m', '', $checksumContents); return trim($checksumContents); } protected function checksumPath($path) { return dirname($path) . '/.checksums/' . basename($path, '.yml') . '.md5'; } protected function calculateChecksum($data) { return md5($data); } protected function determineConvertedFilename($legacyFile) { $convertedFile = preg_replace('#\.alias(|es)\.drushrc\.php$#', '.site.yml', $legacyFile); // Sanity check: if no replacement was done on the filesystem, then // we will presume that no conversion is needed here after all. if ($convertedFile == $legacyFile) { return false; } // If a target directory was set, then the converted file will // be written there. This will be done in writeAll(); we will strip // off everything except for the basename here. If no target // directory was set, then we will keep the path to the converted // file so that it may be written to the correct location. if (!empty($this->target)) { $convertedFile = basename($convertedFile); } $this->cacheConvertedFilePath($legacyFile, $convertedFile); return $convertedFile; } protected function cacheConvertedFilePath($legacyFile, $convertedFile) { $this->convertedFileMap[basename($convertedFile)] = basename($legacyFile); } protected function recoverLegacyFile($convertedFile) { if (!isset($this->convertedFileMap[basename($convertedFile)])) { return false; } return $this->convertedFileMap[basename($convertedFile)]; } protected function checkNeedsConversion($legacyFile, $convertedFile) { // If determineConvertedFilename did not return a valid result, // then force no conversion. if (!$convertedFile) { return; } // Sanity check: the source file must exist. if (!file_exists($legacyFile)) { return false; } // If the target file does not exist, then force a conversion if (!file_exists($convertedFile)) { return true; } // We need to re-convert if the legacy file has been modified // more recently than the converted file. return filemtime($legacyFile) > filemtime($convertedFile); } protected function convertLegacyFile($legacyFile) { $aliases = []; $options = []; // Include the legacy file. In theory, this will define $aliases &/or $options. if (((@include $legacyFile) === false) || (!isset($aliases) && !isset($options))) { // TODO: perhaps we should log a warning? return; } // Decide whether this is a single-alias file or a multiple-alias file. if (preg_match('#\.alias\.drushrc\.php$#', $legacyFile)) { return $this->convertSingleAliasLegacyFile($legacyFile, $options ?: current($aliases)); } return $this->convertMultipleAliasesLegacyFile($legacyFile, $aliases, $options); } protected function convertSingleAliasLegacyFile($legacyFile, $options) { $aliasName = basename($legacyFile, '.alias.drushrc.php'); return $this->convertAlias($aliasName, $options, dirname($legacyFile)); } protected function convertMultipleAliasesLegacyFile($legacyFile, $aliases, $options) { $result = []; foreach ($aliases as $aliasName => $data) { // 'array_merge' is how Drush 8 combines these records. $data = array_merge($options, $data); $convertedAlias = $this->convertAlias($aliasName, $data, dirname($legacyFile)); $result = static::arrayMergeRecursiveDistinct($result, $convertedAlias); } return $result; } protected function convertAlias($aliasName, $data, $dir = '') { $env = 'dev'; // We allow $aliasname to be: // - sitename // - sitename.env // - group.sitename.env // In the case of the last, we will convert to // 'group-sitename.env' (and so on for any additional dots). // First, we will strip off the 'env' if it is present. if (preg_match('/(.*)\.([^.]+)$/', $aliasName, $matches)) { $aliasName = $matches[1]; $env = $matches[2]; } // Convert all remaining dots to dashes. $aliasName = strtr($aliasName, '.', '-'); $data = $this->fixSiteData($data); return $this->convertSingleFileAlias($aliasName, $env, $data, $dir); } protected function fixSiteData($data) { $keyMap = $this->keyConversion(); $options = []; foreach ($data as $key => $value) { if ($key[0] == '#') { unset($data[$key]); } elseif (!isset($keyMap[$key])) { $options[$key] = $data[$key]; unset($data[$key]); } } ksort($options); foreach ($keyMap as $fromKey => $toKey) { if (isset($data[$fromKey]) && ($fromKey != $toKey)) { $data[$toKey] = $data[$fromKey]; unset($data[$fromKey]); } } if (!empty($options)) { $data['options'] = $options; } if (isset($data['paths'])) { $data['paths'] = $this->removePercentFromKey($data['paths']); } ksort($data); return $this->remapData($data); } protected function remapData($data) { $converter = new Data($data); foreach ($this->dataRemap() as $from => $to) { if ($converter->has($from)) { $converter->set($to, $converter->get($from)); $converter->remove($from); } } return $converter->export(); } /** * Anything in the key of the returned array is converted * and written to a new top-level item in the result. * * Anything NOT identified by the key in the returned array * is moved to the 'options' element. */ protected function keyConversion() { return [ 'remote-host' => 'host', 'remote-user' => 'user', 'root' => 'root', 'uri' => 'uri', 'path-aliases' => 'paths', ]; } /** * This table allows for flexible remapping from one location * in the original alias to any other location in the target * alias. * * n.b. Most arbitrary data from the original alias will have * been moved into the 'options' element before this remapping * table is consulted. */ protected function dataRemap() { return [ 'options.ssh-options' => 'ssh.options', ]; } protected function removePercentFromKey($data) { return array_flip( array_map( function ($item) { return ltrim($item, '%'); }, array_flip($data) ) ); } protected function convertSingleFileAlias($aliasName, $env, $data, $dir = '') { $filename = $this->outputFilename($aliasName, '.site.yml', $dir); return [ $filename => [ $env => $data, ], ]; } protected function outputFilename($name, $extension, $dir = '') { $filename = "{$name}{$extension}"; // Just return the filename part if no directory was provided. Also, // the directoy is irrelevant if a target directory is set. if (empty($dir) || !empty($this->target)) { return $filename; } return "$dir/$filename"; } /** * Merges arrays recursively while preserving. * * @param array $array1 * @param array $array2 * * @return array * * @see http://php.net/manual/en/function.array-merge-recursive.php#92195 * @see https://github.com/grasmash/bolt/blob/robo-rebase/src/Robo/Common/ArrayManipulator.php#L22 */ protected static function arrayMergeRecursiveDistinct( array &$array1, array &$array2 ) { $merged = $array1; foreach ($array2 as $key => &$value) { $merged[$key] = self::mergeRecursiveValue($merged, $key, $value); } ksort($merged); return $merged; } /** * Process the value in an arrayMergeRecursiveDistinct - make a recursive * call if needed. */ private static function mergeRecursiveValue(&$merged, $key, $value) { if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) { return self::arrayMergeRecursiveDistinct($merged[$key], $value); } return $value; } }