MergePlugin.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. <?php
  2. /**
  3. * This file is part of the Composer Merge plugin.
  4. *
  5. * Copyright (C) 2015 Bryan Davis, Wikimedia Foundation, and contributors
  6. *
  7. * This software may be modified and distributed under the terms of the MIT
  8. * license. See the LICENSE file for details.
  9. */
  10. namespace Wikimedia\Composer;
  11. use Wikimedia\Composer\Merge\ExtraPackage;
  12. use Wikimedia\Composer\Merge\MissingFileException;
  13. use Wikimedia\Composer\Merge\PluginState;
  14. use Composer\Composer;
  15. use Composer\DependencyResolver\Operation\InstallOperation;
  16. use Composer\EventDispatcher\Event as BaseEvent;
  17. use Composer\EventDispatcher\EventSubscriberInterface;
  18. use Composer\Factory;
  19. use Composer\Installer;
  20. use Composer\Installer\InstallerEvent;
  21. use Composer\Installer\InstallerEvents;
  22. use Composer\Installer\PackageEvent;
  23. use Composer\Installer\PackageEvents;
  24. use Composer\IO\IOInterface;
  25. use Composer\Package\RootPackageInterface;
  26. use Composer\Plugin\PluginInterface;
  27. use Composer\Script\Event as ScriptEvent;
  28. use Composer\Script\ScriptEvents;
  29. /**
  30. * Composer plugin that allows merging multiple composer.json files.
  31. *
  32. * When installed, this plugin will look for a "merge-plugin" key in the
  33. * composer configuration's "extra" section. The value for this key is
  34. * a set of options configuring the plugin.
  35. *
  36. * An "include" setting is required. The value of this setting can be either
  37. * a single value or an array of values. Each value is treated as a glob()
  38. * pattern identifying additional composer.json style configuration files to
  39. * merge into the configuration for the current compser execution.
  40. *
  41. * The "autoload", "autoload-dev", "conflict", "provide", "replace",
  42. * "repositories", "require", "require-dev", and "suggest" sections of the
  43. * found configuration files will be merged into the root package
  44. * configuration as though they were directly included in the top-level
  45. * composer.json file.
  46. *
  47. * If included files specify conflicting package versions for "require" or
  48. * "require-dev", the normal Composer dependency solver process will be used
  49. * to attempt to resolve the conflict. Specifying the 'replace' key as true will
  50. * change this default behaviour so that the last-defined version of a package
  51. * will win, allowing for force-overrides of package defines.
  52. *
  53. * By default the "extra" section is not merged. This can be enabled by
  54. * setitng the 'merge-extra' key to true. In normal mode, when the same key is
  55. * found in both the original and the imported extra section, the version in
  56. * the original config is used and the imported version is skipped. If
  57. * 'replace' mode is active, this behaviour changes so the imported version of
  58. * the key is used, replacing the version in the original config.
  59. *
  60. *
  61. * @code
  62. * {
  63. * "require": {
  64. * "wikimedia/composer-merge-plugin": "dev-master"
  65. * },
  66. * "extra": {
  67. * "merge-plugin": {
  68. * "include": [
  69. * "composer.local.json"
  70. * ]
  71. * }
  72. * }
  73. * }
  74. * @endcode
  75. *
  76. * @author Bryan Davis <bd808@bd808.com>
  77. */
  78. class MergePlugin implements PluginInterface, EventSubscriberInterface
  79. {
  80. /**
  81. * Offical package name
  82. */
  83. const PACKAGE_NAME = 'wikimedia/composer-merge-plugin';
  84. /**
  85. * Name of the composer 1.1 init event.
  86. */
  87. const COMPAT_PLUGINEVENTS_INIT = 'init';
  88. /**
  89. * Priority that plugin uses to register callbacks.
  90. */
  91. const CALLBACK_PRIORITY = 50000;
  92. /**
  93. * @var Composer $composer
  94. */
  95. protected $composer;
  96. /**
  97. * @var PluginState $state
  98. */
  99. protected $state;
  100. /**
  101. * @var Logger $logger
  102. */
  103. protected $logger;
  104. /**
  105. * Files that have already been fully processed
  106. *
  107. * @var string[] $loaded
  108. */
  109. protected $loaded = array();
  110. /**
  111. * Files that have already been partially processed
  112. *
  113. * @var string[] $loadedNoDev
  114. */
  115. protected $loadedNoDev = array();
  116. /**
  117. * {@inheritdoc}
  118. */
  119. public function activate(Composer $composer, IOInterface $io)
  120. {
  121. $this->composer = $composer;
  122. $this->state = new PluginState($this->composer);
  123. $this->logger = new Logger('merge-plugin', $io);
  124. }
  125. /**
  126. * {@inheritdoc}
  127. */
  128. public static function getSubscribedEvents()
  129. {
  130. return array(
  131. // Use our own constant to make this event optional. Once
  132. // composer-1.1 is required, this can use PluginEvents::INIT
  133. // instead.
  134. self::COMPAT_PLUGINEVENTS_INIT =>
  135. array('onInit', self::CALLBACK_PRIORITY),
  136. InstallerEvents::PRE_DEPENDENCIES_SOLVING =>
  137. array('onDependencySolve', self::CALLBACK_PRIORITY),
  138. PackageEvents::POST_PACKAGE_INSTALL =>
  139. array('onPostPackageInstall', self::CALLBACK_PRIORITY),
  140. ScriptEvents::POST_INSTALL_CMD =>
  141. array('onPostInstallOrUpdate', self::CALLBACK_PRIORITY),
  142. ScriptEvents::POST_UPDATE_CMD =>
  143. array('onPostInstallOrUpdate', self::CALLBACK_PRIORITY),
  144. ScriptEvents::PRE_AUTOLOAD_DUMP =>
  145. array('onInstallUpdateOrDump', self::CALLBACK_PRIORITY),
  146. ScriptEvents::PRE_INSTALL_CMD =>
  147. array('onInstallUpdateOrDump', self::CALLBACK_PRIORITY),
  148. ScriptEvents::PRE_UPDATE_CMD =>
  149. array('onInstallUpdateOrDump', self::CALLBACK_PRIORITY),
  150. );
  151. }
  152. /**
  153. * Handle an event callback for initialization.
  154. *
  155. * @param \Composer\EventDispatcher\Event $event
  156. */
  157. public function onInit(BaseEvent $event)
  158. {
  159. $this->state->loadSettings();
  160. // It is not possible to know if the user specified --dev or --no-dev
  161. // so assume it is false. The dev section will be merged later when
  162. // the other events fire.
  163. $this->state->setDevMode(false);
  164. $this->mergeFiles($this->state->getIncludes(), false);
  165. $this->mergeFiles($this->state->getRequires(), true);
  166. }
  167. /**
  168. * Handle an event callback for an install, update or dump command by
  169. * checking for "merge-plugin" in the "extra" data and merging package
  170. * contents if found.
  171. *
  172. * @param ScriptEvent $event
  173. */
  174. public function onInstallUpdateOrDump(ScriptEvent $event)
  175. {
  176. $this->state->loadSettings();
  177. $this->state->setDevMode($event->isDevMode());
  178. $this->mergeFiles($this->state->getIncludes(), false);
  179. $this->mergeFiles($this->state->getRequires(), true);
  180. if ($event->getName() === ScriptEvents::PRE_AUTOLOAD_DUMP) {
  181. $this->state->setDumpAutoloader(true);
  182. $flags = $event->getFlags();
  183. if (isset($flags['optimize'])) {
  184. $this->state->setOptimizeAutoloader($flags['optimize']);
  185. }
  186. }
  187. }
  188. /**
  189. * Find configuration files matching the configured glob patterns and
  190. * merge their contents with the master package.
  191. *
  192. * @param array $patterns List of files/glob patterns
  193. * @param bool $required Are the patterns required to match files?
  194. * @throws MissingFileException when required and a pattern returns no
  195. * results
  196. */
  197. protected function mergeFiles(array $patterns, $required = false)
  198. {
  199. $root = $this->composer->getPackage();
  200. $files = array_map(
  201. function ($files, $pattern) use ($required) {
  202. if ($required && !$files) {
  203. throw new MissingFileException(
  204. "merge-plugin: No files matched required '{$pattern}'"
  205. );
  206. }
  207. return $files;
  208. },
  209. array_map('glob', $patterns),
  210. $patterns
  211. );
  212. foreach (array_reduce($files, 'array_merge', array()) as $path) {
  213. $this->mergeFile($root, $path);
  214. }
  215. }
  216. /**
  217. * Read a JSON file and merge its contents
  218. *
  219. * @param RootPackageInterface $root
  220. * @param string $path
  221. */
  222. protected function mergeFile(RootPackageInterface $root, $path)
  223. {
  224. if (isset($this->loaded[$path]) ||
  225. (isset($this->loadedNoDev[$path]) && !$this->state->isDevMode())
  226. ) {
  227. $this->logger->debug(
  228. "Already merged <comment>$path</comment> completely"
  229. );
  230. return;
  231. }
  232. $package = new ExtraPackage($path, $this->composer, $this->logger);
  233. if (isset($this->loadedNoDev[$path])) {
  234. $this->logger->info(
  235. "Loading -dev sections of <comment>{$path}</comment>..."
  236. );
  237. $package->mergeDevInto($root, $this->state);
  238. } else {
  239. $this->logger->info("Loading <comment>{$path}</comment>...");
  240. $package->mergeInto($root, $this->state);
  241. }
  242. if ($this->state->isDevMode()) {
  243. $this->loaded[$path] = true;
  244. } else {
  245. $this->loadedNoDev[$path] = true;
  246. }
  247. if ($this->state->recurseIncludes()) {
  248. $this->mergeFiles($package->getIncludes(), false);
  249. $this->mergeFiles($package->getRequires(), true);
  250. }
  251. }
  252. /**
  253. * Handle an event callback for pre-dependency solving phase of an install
  254. * or update by adding any duplicate package dependencies found during
  255. * initial merge processing to the request that will be processed by the
  256. * dependency solver.
  257. *
  258. * @param InstallerEvent $event
  259. */
  260. public function onDependencySolve(InstallerEvent $event)
  261. {
  262. $request = $event->getRequest();
  263. foreach ($this->state->getDuplicateLinks('require') as $link) {
  264. $this->logger->info(
  265. "Adding dependency <comment>{$link}</comment>"
  266. );
  267. $request->install($link->getTarget(), $link->getConstraint());
  268. }
  269. // Issue #113: Check devMode of event rather than our global state.
  270. // Composer fires the PRE_DEPENDENCIES_SOLVING event twice for
  271. // `--no-dev` operations to decide which packages are dev only
  272. // requirements.
  273. if ($this->state->shouldMergeDev() && $event->isDevMode()) {
  274. foreach ($this->state->getDuplicateLinks('require-dev') as $link) {
  275. $this->logger->info(
  276. "Adding dev dependency <comment>{$link}</comment>"
  277. );
  278. $request->install($link->getTarget(), $link->getConstraint());
  279. }
  280. }
  281. }
  282. /**
  283. * Handle an event callback following installation of a new package by
  284. * checking to see if the package that was installed was our plugin.
  285. *
  286. * @param PackageEvent $event
  287. */
  288. public function onPostPackageInstall(PackageEvent $event)
  289. {
  290. $op = $event->getOperation();
  291. if ($op instanceof InstallOperation) {
  292. $package = $op->getPackage()->getName();
  293. if ($package === self::PACKAGE_NAME) {
  294. $this->logger->info('composer-merge-plugin installed');
  295. $this->state->setFirstInstall(true);
  296. $this->state->setLocked(
  297. $event->getComposer()->getLocker()->isLocked()
  298. );
  299. }
  300. }
  301. }
  302. /**
  303. * Handle an event callback following an install or update command. If our
  304. * plugin was installed during the run then trigger an update command to
  305. * process any merge-patterns in the current config.
  306. *
  307. * @param ScriptEvent $event
  308. */
  309. public function onPostInstallOrUpdate(ScriptEvent $event)
  310. {
  311. // @codeCoverageIgnoreStart
  312. if ($this->state->isFirstInstall()) {
  313. $this->state->setFirstInstall(false);
  314. $this->logger->info(
  315. '<comment>' .
  316. 'Running additional update to apply merge settings' .
  317. '</comment>'
  318. );
  319. $config = $this->composer->getConfig();
  320. $preferSource = $config->get('preferred-install') == 'source';
  321. $preferDist = $config->get('preferred-install') == 'dist';
  322. $installer = Installer::create(
  323. $event->getIO(),
  324. // Create a new Composer instance to ensure full processing of
  325. // the merged files.
  326. Factory::create($event->getIO(), null, false)
  327. );
  328. $installer->setPreferSource($preferSource);
  329. $installer->setPreferDist($preferDist);
  330. $installer->setDevMode($event->isDevMode());
  331. $installer->setDumpAutoloader($this->state->shouldDumpAutoloader());
  332. $installer->setOptimizeAutoloader(
  333. $this->state->shouldOptimizeAutoloader()
  334. );
  335. if ($this->state->forceUpdate()) {
  336. // Force update mode so that new packages are processed rather
  337. // than just telling the user that composer.json and
  338. // composer.lock don't match.
  339. $installer->setUpdate(true);
  340. }
  341. $installer->run();
  342. }
  343. // @codeCoverageIgnoreEnd
  344. }
  345. }
  346. // vim:sw=4:ts=4:sts=4:et: