├── LICENSE ├── README.md ├── composer.json └── src ├── ExtraPackage.php ├── Logger.php ├── MergePlugin.php ├── MissingFileException.php ├── MultiConstraint.php ├── NestedArray.php ├── PluginState.php └── StabilityFlags.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Bryan Davis, Wikimedia Foundation, and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Stable Version]](https://packagist.org/packages/wikimedia/composer-merge-plugin) [![License]](https://github.com/wikimedia/composer-merge-plugin/blob/master/LICENSE) 2 | [![Build Status]](https://github.com/wikimedia/composer-merge-plugin/actions/workflows/CI.yaml) 3 | [![Code Coverage]](https://scrutinizer-ci.com/g/wikimedia/composer-merge-plugin/?branch=master) 4 | 5 | Composer Merge Plugin 6 | ===================== 7 | 8 | Merge multiple composer.json files at [Composer] runtime. 9 | 10 | Composer Merge Plugin is intended to allow easier dependency management for 11 | applications which ship a composer.json file and expect some deployments to 12 | install additional Composer managed libraries. It does this by allowing the 13 | application's top level `composer.json` file to provide a list of optional 14 | additional configuration files. When Composer is run it will parse these files 15 | and merge their configuration settings into the base configuration. This 16 | combined configuration will then be used when downloading additional libraries 17 | and generating the autoloader. 18 | 19 | Composer Merge Plugin was created to help with installation of [MediaWiki] 20 | which has core library requirements as well as optional libraries and 21 | extensions which may be managed via Composer. 22 | 23 | 24 | Installation 25 | ------------ 26 | 27 | Composer Merge Plugin 1.4.x (and older) requires Composer 1.x. 28 | 29 | Composer Merge Plugin 2.0.x (and newer) is compatible with both Composer 2.x and 1.x. 30 | 31 | ``` 32 | $ composer require wikimedia/composer-merge-plugin 33 | ``` 34 | 35 | ### Upgrading from Composer 1 to 2 36 | 37 | If you are already using Composer Merge Plugin 1.4 (or older) and you are updating the plugin to 2.0 (or newer), it is recommended that you update the plugin first using Composer 1. 38 | 39 | If you update the incompatible plugin using Composer 2, the plugin will be ignored: 40 | 41 | > The "wikimedia/composer-merge-plugin" plugin was skipped because it requires a Plugin API version ("^1.0") that does not match your Composer installation ("2.0.0"). You may need to run composer update with the "--no-plugins" option. 42 | 43 | Consequently, Composer will be unaware of the merged dependencies and will remove them requiring you to run `composer update` again to reinstall merged dependencies. 44 | 45 | 46 | Usage 47 | ----- 48 | 49 | ```json 50 | { 51 | "require": { 52 | "wikimedia/composer-merge-plugin": "dev-master" 53 | }, 54 | "extra": { 55 | "merge-plugin": { 56 | "include": [ 57 | "composer.local.json", 58 | "extensions/*/composer.json" 59 | ], 60 | "require": [ 61 | "submodule/composer.json" 62 | ], 63 | "recurse": true, 64 | "replace": false, 65 | "ignore-duplicates": false, 66 | "merge-dev": true, 67 | "merge-extra": false, 68 | "merge-extra-deep": false, 69 | "merge-replace": true, 70 | "merge-scripts": false 71 | } 72 | } 73 | } 74 | ``` 75 | 76 | ### Updating sub-levels `composer.json` files 77 | 78 | 79 | In order for Composer Merge Plugin to install dependencies from updated or newly created sub-level `composer.json` files in your project you need to run the command: 80 | 81 | ``` 82 | $ composer update 83 | ``` 84 | 85 | This will [instruct Composer to recalculate the file hash](https://getcomposer.org/doc/03-cli.md#update) for the top-level `composer.json` thus triggering Composer Merge Plugin to look for the sub-level configuration files and update your dependencies. 86 | 87 | 88 | Plugin configuration 89 | -------------------- 90 | 91 | The plugin reads its configuration from the `merge-plugin` section of your 92 | composer.json's `extra` section. An `include` setting is required to tell 93 | Composer Merge Plugin which file(s) to merge. 94 | 95 | 96 | ### include 97 | 98 | The `include` setting can specify either a single value or an array of values. 99 | Each value is treated as a PHP `glob()` pattern identifying additional 100 | composer.json style configuration files to merge into the root package 101 | configuration for the current Composer execution. 102 | 103 | The following sections of the found configuration files will be merged into 104 | the Composer root package configuration as though they were directly included 105 | in the top-level composer.json file: 106 | 107 | * [autoload](https://getcomposer.org/doc/04-schema.md#autoload) 108 | * [autoload-dev](https://getcomposer.org/doc/04-schema.md#autoload-dev) 109 | (optional, see [merge-dev](#merge-dev) below) 110 | * [conflict](https://getcomposer.org/doc/04-schema.md#conflict) 111 | * [provide](https://getcomposer.org/doc/04-schema.md#provide) 112 | * [replace](https://getcomposer.org/doc/04-schema.md#replace) 113 | (optional, see [merge-replace](#merge-replace) below) 114 | * [repositories](https://getcomposer.org/doc/04-schema.md#repositories) 115 | * [require](https://getcomposer.org/doc/04-schema.md#require) 116 | * [require-dev](https://getcomposer.org/doc/04-schema.md#require-dev) 117 | (optional, see [merge-dev](#merge-dev) below) 118 | * [suggest](https://getcomposer.org/doc/04-schema.md#suggest) 119 | * [extra](https://getcomposer.org/doc/04-schema.md#extra) 120 | (optional, see [merge-extra](#merge-extra) below) 121 | * [scripts](https://getcomposer.org/doc/04-schema.md#scripts) 122 | (optional, see [merge-scripts](#merge-scripts) below) 123 | 124 | 125 | ### require 126 | 127 | The `require` setting is identical to [`include`](#include) except when 128 | a pattern fails to match at least one file then it will cause an error. 129 | 130 | ### recurse 131 | 132 | By default the merge plugin is recursive; if an included file has 133 | a `merge-plugin` section it will also be processed. This functionality can be 134 | disabled by adding a `"recurse": false` setting. 135 | 136 | 137 | ### replace 138 | 139 | By default, Composer's conflict resolution engine is used to determine which 140 | version of a package should be installed when multiple files specify the same 141 | package. A `"replace": true` setting can be provided to change to a "last 142 | version specified wins" conflict resolution strategy. In this mode, duplicate 143 | package declarations found in merged files will overwrite the declarations 144 | made by earlier files. Files are loaded in the order specified by the 145 | `include` setting with globbed files being processed in alphabetical order. 146 | 147 | ### ignore-duplicates 148 | 149 | By default, Composer's conflict resolution engine is used to determine which 150 | version of a package should be installed when multiple files specify the same 151 | package. An `"ignore-duplicates": true` setting can be provided to change to 152 | a "first version specified wins" conflict resolution strategy. In this mode, 153 | duplicate package declarations found in merged files will be ignored in favor 154 | of the declarations made by earlier files. Files are loaded in the order 155 | specified by the `include` setting with globbed files being processed in 156 | alphabetical order. 157 | 158 | Note: `"replace": true` and `"ignore-duplicates": true` modes are mutually 159 | exclusive. If both are set, `"ignore-duplicates": true` will be used. 160 | 161 | ### merge-dev 162 | 163 | By default, `autoload-dev` and `require-dev` sections of included files are 164 | merged. A `"merge-dev": false` setting will disable this behavior. 165 | 166 | 167 | ### merge-extra 168 | 169 | A `"merge-extra": true` setting enables the merging the contents of the 170 | `extra` section of included files as well. The normal merge mode for the extra 171 | section is to accept the first version of any key found (e.g. a key in the 172 | master config wins over the version found in any imported config). If 173 | `replace` mode is active ([see above](#replace)) then this behavior changes 174 | and the last key found will win (e.g. the key in the master config is replaced 175 | by the key in the imported config). If `"merge-extra-deep": true` is specified 176 | then, the sections are merged similar to array_merge_recursive() - however 177 | duplicate string array keys are replaced instead of merged, while numeric 178 | array keys are merged as usual. The usefulness of merging the extra section 179 | will vary depending on the Composer plugins being used and the order in which 180 | they are processed by Composer. 181 | 182 | Note that `merge-plugin` sections are excluded from the merge process, but are 183 | always processed by the plugin unless [recursion](#recurse) is disabled. 184 | 185 | ### merge-replace 186 | 187 | By default, the `replace` section of included files are merged. 188 | A `"merge-replace": false` setting will disable this behavior. 189 | 190 | ### merge-scripts 191 | 192 | A `"merge-scripts": true` setting enables merging the contents of the 193 | `scripts` section of included files as well. The normal merge mode for the 194 | scripts section is to accept the first version of any key found (e.g. a key in 195 | the master config wins over the version found in any imported config). If 196 | `replace` mode is active ([see above](#replace)) then this behavior changes 197 | and the last key found will win (e.g. the key in the master config is replaced 198 | by the key in the imported config). 199 | 200 | Note: [custom commands][] added by merged configuration will work when invoked 201 | as `composer run-script my-cool-command` but will not be available using the 202 | normal `composer my-cool-command` shortcut. 203 | 204 | 205 | Running tests 206 | ------------- 207 | 208 | ``` 209 | $ composer install 210 | $ composer test 211 | ``` 212 | 213 | 214 | Contributing 215 | ------------ 216 | 217 | Bug, feature requests and other issues should be reported to the [GitHub 218 | project]. We accept code and documentation contributions via Pull Requests on 219 | GitHub as well. 220 | 221 | - [PSR-2 Coding Standard][] is used by the project. The included test 222 | configuration uses [PHP_CodeSniffer][] to validate the conventions. 223 | - Tests are encouraged. Our test coverage isn't perfect but we'd like it to 224 | get better rather than worse, so please try to include tests with your 225 | changes. 226 | - Keep the documentation up to date. Make sure `README.md` and other 227 | relevant documentation is kept up to date with your changes. 228 | - One pull request per feature. Try to keep your changes focused on solving 229 | a single problem. This will make it easier for us to review the change and 230 | easier for you to make sure you have updated the necessary tests and 231 | documentation. 232 | 233 | 234 | License 235 | ------- 236 | 237 | Composer Merge plugin is licensed under the MIT license. See the 238 | [`LICENSE`](LICENSE) file for more details. 239 | 240 | 241 | --- 242 | [Composer]: https://getcomposer.org/ 243 | [MediaWiki]: https://www.mediawiki.org/wiki/MediaWiki 244 | [GitHub project]: https://github.com/wikimedia/composer-merge-plugin 245 | [PSR-2 Coding Standard]: https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md 246 | [PHP_CodeSniffer]: http://pear.php.net/package/PHP_CodeSniffer 247 | [Latest Stable Version]: https://img.shields.io/packagist/v/wikimedia/composer-merge-plugin.svg?style=flat 248 | [License]: https://img.shields.io/packagist/l/wikimedia/composer-merge-plugin.svg?style=flat 249 | [Build Status]: https://github.com/wikimedia/composer-merge-plugin/actions/workflows/CI.yaml/badge.svg 250 | [Code Coverage]: https://img.shields.io/scrutinizer/coverage/g/wikimedia/composer-merge-plugin/master.svg?style=flat 251 | [custom commands]: https://getcomposer.org/doc/articles/scripts.md#writing-custom-commands 252 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wikimedia/composer-merge-plugin", 3 | "description": "Composer plugin to merge multiple composer.json files", 4 | "type": "composer-plugin", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Bryan Davis", 9 | "email": "bd808@wikimedia.org" 10 | } 11 | ], 12 | "minimum-stability": "dev", 13 | "prefer-stable": true, 14 | "require": { 15 | "php": ">=7.4.0", 16 | "composer-plugin-api": "^1.1||^2.0" 17 | }, 18 | "require-dev": { 19 | "ext-json": "*", 20 | "composer/composer": "^1.1||^2.0", 21 | "mediawiki/mediawiki-phan-config": "0.11.1", 22 | "php-parallel-lint/php-parallel-lint": "~1.3.1", 23 | "phpspec/prophecy-phpunit": "~2.0.1", 24 | "phpunit/phpunit": "^9.5", 25 | "squizlabs/php_codesniffer": "~3.7.1" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "Wikimedia\\Composer\\Merge\\V2\\": "src/" 30 | } 31 | }, 32 | "extra": { 33 | "branch-alias": { 34 | "dev-master": "2.x-dev" 35 | }, 36 | "class": "Wikimedia\\Composer\\Merge\\V2\\MergePlugin" 37 | }, 38 | "config": { 39 | "optimize-autoloader": true, 40 | "sort-packages": true 41 | }, 42 | "scripts": { 43 | "coverage": [ 44 | "phpunit --log-junit=reports/unitreport.xml --coverage-text --coverage-html=reports/coverage --coverage-clover=reports/coverage.xml", 45 | "phpcs --encoding=utf-8 --standard=PSR2 --report-checkstyle=reports/checkstyle-phpcs.xml --report-full --extensions=php src/* tests/phpunit/*" 46 | ], 47 | "phan": "phan -d . --long-progress-bar --allow-polyfill-parser", 48 | "phpcs": "phpcs --encoding=utf-8 --standard=PSR2 --extensions=php src/* tests/phpunit/*", 49 | "phpunit": "phpunit", 50 | "test": [ 51 | "composer validate --no-interaction", 52 | "parallel-lint src tests", 53 | "@phpunit", 54 | "@phpcs" 55 | ] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ExtraPackage.php: -------------------------------------------------------------------------------- 1 | 31 | */ 32 | class ExtraPackage 33 | { 34 | 35 | /** 36 | * @var Composer $composer 37 | */ 38 | protected $composer; 39 | 40 | /** 41 | * @var Logger $logger 42 | */ 43 | protected $logger; 44 | 45 | /** 46 | * @var string $path 47 | */ 48 | protected $path; 49 | 50 | /** 51 | * @var array $json 52 | */ 53 | protected $json; 54 | 55 | /** 56 | * @var CompletePackage $package 57 | */ 58 | protected $package; 59 | 60 | /** 61 | * @var array $mergedRequirements 62 | */ 63 | protected $mergedRequirements = []; 64 | 65 | /** 66 | * @var VersionParser $versionParser 67 | */ 68 | protected $versionParser; 69 | 70 | /** 71 | * @param string $path Path to composer.json file 72 | * @param Composer $composer 73 | * @param Logger $logger 74 | */ 75 | public function __construct($path, Composer $composer, Logger $logger) 76 | { 77 | $this->path = $path; 78 | $this->composer = $composer; 79 | $this->logger = $logger; 80 | $this->json = $this->readPackageJson($path); 81 | $this->package = $this->loadPackage($this->json); 82 | $this->versionParser = new VersionParser(); 83 | } 84 | 85 | /** 86 | * Get list of additional packages to include if precessing recursively. 87 | * 88 | * @return array 89 | */ 90 | public function getIncludes() 91 | { 92 | return isset($this->json['extra']['merge-plugin']['include']) ? 93 | $this->fixRelativePaths($this->json['extra']['merge-plugin']['include']) : []; 94 | } 95 | 96 | /** 97 | * Get list of additional packages to require if precessing recursively. 98 | * 99 | * @return array 100 | */ 101 | public function getRequires() 102 | { 103 | return isset($this->json['extra']['merge-plugin']['require']) ? 104 | $this->fixRelativePaths($this->json['extra']['merge-plugin']['require']) : []; 105 | } 106 | 107 | /** 108 | * Get list of merged requirements from this package. 109 | * 110 | * @return string[] 111 | */ 112 | public function getMergedRequirements() 113 | { 114 | return array_keys($this->mergedRequirements); 115 | } 116 | 117 | /** 118 | * Read the contents of a composer.json style file into an array. 119 | * 120 | * The package contents are fixed up to be usable to create a Package 121 | * object by providing dummy "name" and "version" values if they have not 122 | * been provided in the file. This is consistent with the default root 123 | * package loading behavior of Composer. 124 | * 125 | * @param string $path 126 | * @return array 127 | */ 128 | protected function readPackageJson($path) 129 | { 130 | $file = new JsonFile($path); 131 | $json = $file->read(); 132 | if (!isset($json['name'])) { 133 | $json['name'] = 'merge-plugin/' . 134 | strtr($path, DIRECTORY_SEPARATOR, '-'); 135 | } 136 | if (!isset($json['version'])) { 137 | $json['version'] = '1.0.0'; 138 | } 139 | return $json; 140 | } 141 | 142 | /** 143 | * @param array $json 144 | * @return CompletePackage 145 | */ 146 | protected function loadPackage(array $json) 147 | { 148 | $loader = new ArrayLoader(); 149 | $package = $loader->load($json); 150 | // @codeCoverageIgnoreStart 151 | if (!$package instanceof CompletePackage) { 152 | throw new UnexpectedValueException( 153 | 'Expected instance of CompletePackage, got ' . 154 | get_class($package) 155 | ); 156 | } 157 | // @codeCoverageIgnoreEnd 158 | return $package; 159 | } 160 | 161 | /** 162 | * Merge this package into a RootPackageInterface 163 | * 164 | * @param RootPackageInterface $root 165 | * @param PluginState $state 166 | */ 167 | public function mergeInto(RootPackageInterface $root, PluginState $state) 168 | { 169 | $this->prependRepositories($root); 170 | 171 | $this->mergeRequires('require', $root, $state); 172 | 173 | $this->mergePackageLinks('conflict', $root); 174 | 175 | if ($state->shouldMergeReplace()) { 176 | $this->mergePackageLinks('replace', $root); 177 | } 178 | 179 | $this->mergePackageLinks('provide', $root); 180 | 181 | $this->mergeSuggests($root); 182 | 183 | $this->mergeAutoload('autoload', $root); 184 | 185 | $this->mergeExtra($root, $state); 186 | 187 | $this->mergeScripts($root, $state); 188 | 189 | if ($state->isDevMode()) { 190 | $this->mergeDevInto($root, $state); 191 | } else { 192 | $this->mergeReferences($root); 193 | $this->mergeAliases($root); 194 | } 195 | } 196 | 197 | /** 198 | * Merge just the dev portion into a RootPackageInterface 199 | * 200 | * @param RootPackageInterface $root 201 | * @param PluginState $state 202 | */ 203 | public function mergeDevInto(RootPackageInterface $root, PluginState $state) 204 | { 205 | $this->mergeRequires('require-dev', $root, $state); 206 | $this->mergeAutoload('devAutoload', $root); 207 | $this->mergeReferences($root); 208 | $this->mergeAliases($root); 209 | } 210 | 211 | /** 212 | * Add a collection of repositories described by the given configuration 213 | * to the given package and the global repository manager. 214 | * 215 | * @param RootPackageInterface $root 216 | */ 217 | protected function prependRepositories(RootPackageInterface $root) 218 | { 219 | if (!isset($this->json['repositories'])) { 220 | return; 221 | } 222 | $repoManager = $this->composer->getRepositoryManager(); 223 | $newRepos = []; 224 | 225 | foreach ($this->json['repositories'] as $repoJson) { 226 | if (!isset($repoJson['type'])) { 227 | continue; 228 | } 229 | if ($repoJson['type'] === 'path' && isset($repoJson['url'])) { 230 | $repoJson['url'] = $this->fixRelativePaths(array($repoJson['url']))[0]; 231 | } 232 | $this->logger->info("Prepending {$repoJson['type']} repository"); 233 | $repo = $repoManager->createRepository( 234 | $repoJson['type'], 235 | $repoJson 236 | ); 237 | $repoManager->prependRepository($repo); 238 | $newRepos[] = $repo; 239 | } 240 | 241 | $unwrapped = self::unwrapIfNeeded($root, 'setRepositories'); 242 | $unwrapped->setRepositories(array_merge( 243 | $newRepos, 244 | $root->getRepositories() 245 | )); 246 | } 247 | 248 | /** 249 | * Merge require or require-dev into a RootPackageInterface 250 | * 251 | * @param string $type 'require' or 'require-dev' 252 | * @param RootPackageInterface $root 253 | * @param PluginState $state 254 | */ 255 | protected function mergeRequires( 256 | $type, 257 | RootPackageInterface $root, 258 | PluginState $state 259 | ) { 260 | $linkType = BasePackage::$supportedLinkTypes[$type]; 261 | $getter = 'get' . ucfirst($linkType['method']); 262 | $setter = 'set' . ucfirst($linkType['method']); 263 | 264 | $requires = $this->package->{$getter}(); 265 | if (empty($requires)) { 266 | return; 267 | } 268 | 269 | $this->mergeStabilityFlags($root, $requires); 270 | 271 | $requires = $this->replaceSelfVersionDependencies( 272 | $type, 273 | $requires, 274 | $root 275 | ); 276 | 277 | $root->{$setter}($this->mergeOrDefer( 278 | $type, 279 | $root->{$getter}(), 280 | $requires, 281 | $state 282 | )); 283 | } 284 | 285 | /** 286 | * Merge two collections of package links and collect duplicates for 287 | * subsequent processing. 288 | * 289 | * @param string $type 'require' or 'require-dev' 290 | * @param array $origin Primary collection 291 | * @param array $merge Additional collection 292 | * @param PluginState $state 293 | * @return array Merged collection 294 | */ 295 | protected function mergeOrDefer( 296 | $type, 297 | array $origin, 298 | array $merge, 299 | PluginState $state 300 | ) { 301 | if ($state->ignoreDuplicateLinks() && $state->replaceDuplicateLinks()) { 302 | $this->logger->warning("Both replace and ignore-duplicates are true. These are mutually exclusive."); 303 | $this->logger->warning("Duplicate packages will be ignored."); 304 | } 305 | 306 | foreach ($merge as $name => $link) { 307 | if (isset($origin[$name])) { 308 | if ($state->ignoreDuplicateLinks()) { 309 | $this->logger->info("Ignoring duplicate {$name}"); 310 | continue; 311 | } 312 | 313 | if ($state->replaceDuplicateLinks()) { 314 | $this->logger->info("Replacing {$name}"); 315 | $this->mergedRequirements[$name] = true; 316 | $origin[$name] = $link; 317 | } else { 318 | $this->logger->info("Merging {$name}"); 319 | $this->mergedRequirements[$name] = true; 320 | $origin[$name] = $this->mergeConstraints($origin[$name], $link, $state); 321 | } 322 | } else { 323 | $this->logger->info("Adding {$name}"); 324 | $this->mergedRequirements[$name] = true; 325 | $origin[$name] = $link; 326 | } 327 | } 328 | 329 | if (!$state->isComposer1()) { 330 | Intervals::clear(); 331 | } 332 | 333 | return $origin; 334 | } 335 | 336 | /** 337 | * Merge package constraints. 338 | * 339 | * Adapted from Composer's UpdateCommand::appendConstraintToLink 340 | * 341 | * @param Link $origin The base package link. 342 | * @param Link $merge The related package link to merge. 343 | * @param PluginState $state 344 | * @return Link Merged link. 345 | */ 346 | protected function mergeConstraints(Link $origin, Link $merge, PluginState $state) 347 | { 348 | $oldPrettyString = $origin->getConstraint()->getPrettyString(); 349 | $newPrettyString = $merge->getConstraint()->getPrettyString(); 350 | 351 | if ($state->isComposer1()) { 352 | $constraintClass = MultiConstraint::class; 353 | } else { 354 | $constraintClass = \Composer\Semver\Constraint\MultiConstraint::class; 355 | 356 | if (Intervals::isSubsetOf($origin->getConstraint(), $merge->getConstraint())) { 357 | return $origin; 358 | } 359 | 360 | if (Intervals::isSubsetOf($merge->getConstraint(), $origin->getConstraint())) { 361 | return $merge; 362 | } 363 | } 364 | 365 | $newConstraint = $constraintClass::create([ 366 | $origin->getConstraint(), 367 | $merge->getConstraint() 368 | ], true); 369 | $newConstraint->setPrettyString($oldPrettyString.', '.$newPrettyString); 370 | 371 | return new Link( 372 | $origin->getSource(), 373 | $origin->getTarget(), 374 | $newConstraint, 375 | $origin->getDescription(), 376 | $origin->getPrettyConstraint() . ', ' . $newPrettyString 377 | ); 378 | } 379 | 380 | /** 381 | * Merge autoload or autoload-dev into a RootPackageInterface 382 | * 383 | * @param string $type 'autoload' or 'devAutoload' 384 | * @param RootPackageInterface $root 385 | */ 386 | protected function mergeAutoload($type, RootPackageInterface $root) 387 | { 388 | $getter = 'get' . ucfirst($type); 389 | $setter = 'set' . ucfirst($type); 390 | 391 | $autoload = $this->package->{$getter}(); 392 | if (empty($autoload)) { 393 | return; 394 | } 395 | 396 | $unwrapped = self::unwrapIfNeeded($root, $setter); 397 | $unwrapped->{$setter}(array_merge_recursive( 398 | $root->{$getter}(), 399 | $this->fixRelativePaths($autoload) 400 | )); 401 | } 402 | 403 | /** 404 | * Fix a collection of paths that are relative to this package to be 405 | * relative to the base package. 406 | * 407 | * @param array $paths 408 | * @return array 409 | */ 410 | protected function fixRelativePaths(array $paths) 411 | { 412 | $base = dirname($this->path); 413 | $base = ($base === '.') ? '' : "{$base}/"; 414 | 415 | array_walk_recursive( 416 | $paths, 417 | function (&$path) use ($base) { 418 | $path = "{$base}{$path}"; 419 | } 420 | ); 421 | return $paths; 422 | } 423 | 424 | /** 425 | * Extract and merge stability flags from the given collection of 426 | * requires and merge them into a RootPackageInterface 427 | * 428 | * @param RootPackageInterface $root 429 | * @param array $requires 430 | */ 431 | protected function mergeStabilityFlags( 432 | RootPackageInterface $root, 433 | array $requires 434 | ) { 435 | $flags = $root->getStabilityFlags(); 436 | $sf = new StabilityFlags($flags, $root->getMinimumStability()); 437 | 438 | $unwrapped = self::unwrapIfNeeded($root, 'setStabilityFlags'); 439 | $unwrapped->setStabilityFlags(array_merge( 440 | $flags, 441 | $sf->extractAll($requires) 442 | )); 443 | } 444 | 445 | /** 446 | * Merge package links of the given type into a RootPackageInterface 447 | * 448 | * @param string $type 'conflict', 'replace' or 'provide' 449 | * @param RootPackageInterface $root 450 | */ 451 | protected function mergePackageLinks($type, RootPackageInterface $root) 452 | { 453 | $linkType = BasePackage::$supportedLinkTypes[$type]; 454 | $getter = 'get' . ucfirst($linkType['method']); 455 | $setter = 'set' . ucfirst($linkType['method']); 456 | 457 | $links = $this->package->{$getter}(); 458 | if (!empty($links)) { 459 | $unwrapped = self::unwrapIfNeeded($root, $setter); 460 | // @codeCoverageIgnoreStart 461 | if ($root !== $unwrapped) { 462 | $this->logger->warning( 463 | 'This Composer version does not support ' . 464 | "'{$type}' merging for aliased packages." 465 | ); 466 | } 467 | // @codeCoverageIgnoreEnd 468 | $unwrapped->{$setter}(array_merge( 469 | $root->{$getter}(), 470 | $this->replaceSelfVersionDependencies($type, $links, $root) 471 | )); 472 | } 473 | } 474 | 475 | /** 476 | * Merge suggested packages into a RootPackageInterface 477 | * 478 | * @param RootPackageInterface $root 479 | */ 480 | protected function mergeSuggests(RootPackageInterface $root) 481 | { 482 | $suggests = $this->package->getSuggests(); 483 | if (!empty($suggests)) { 484 | $unwrapped = self::unwrapIfNeeded($root, 'setSuggests'); 485 | $unwrapped->setSuggests(array_merge( 486 | $root->getSuggests(), 487 | $suggests 488 | )); 489 | } 490 | } 491 | 492 | /** 493 | * Merge extra config into a RootPackageInterface 494 | * 495 | * @param RootPackageInterface $root 496 | * @param PluginState $state 497 | */ 498 | public function mergeExtra(RootPackageInterface $root, PluginState $state) 499 | { 500 | $extra = $this->package->getExtra(); 501 | unset($extra['merge-plugin']); 502 | if (!$state->shouldMergeExtra() || empty($extra)) { 503 | return; 504 | } 505 | 506 | $rootExtra = $root->getExtra(); 507 | $unwrapped = self::unwrapIfNeeded($root, 'setExtra'); 508 | 509 | if ($state->replaceDuplicateLinks()) { 510 | $unwrapped->setExtra( 511 | self::mergeExtraArray($state->shouldMergeExtraDeep(), $rootExtra, $extra) 512 | ); 513 | } else { 514 | if (!$state->shouldMergeExtraDeep()) { 515 | foreach (array_intersect( 516 | array_keys($extra), 517 | array_keys($rootExtra) 518 | ) as $key) { 519 | $this->logger->info( 520 | "Ignoring duplicate {$key} in ". 521 | "{$this->path} extra config." 522 | ); 523 | } 524 | } 525 | $unwrapped->setExtra( 526 | self::mergeExtraArray($state->shouldMergeExtraDeep(), $extra, $rootExtra) 527 | ); 528 | } 529 | } 530 | 531 | /** 532 | * Merge scripts config into a RootPackageInterface 533 | * 534 | * @param RootPackageInterface $root 535 | * @param PluginState $state 536 | */ 537 | public function mergeScripts(RootPackageInterface $root, PluginState $state) 538 | { 539 | $scripts = $this->package->getScripts(); 540 | if (!$state->shouldMergeScripts() || empty($scripts)) { 541 | return; 542 | } 543 | 544 | $rootScripts = $root->getScripts(); 545 | $unwrapped = self::unwrapIfNeeded($root, 'setScripts'); 546 | 547 | if ($state->replaceDuplicateLinks()) { 548 | $unwrapped->setScripts( 549 | array_merge($rootScripts, $scripts) 550 | ); 551 | } else { 552 | $unwrapped->setScripts( 553 | array_merge($scripts, $rootScripts) 554 | ); 555 | } 556 | } 557 | 558 | /** 559 | * Merges two arrays either via arrayMergeDeep or via array_merge. 560 | * 561 | * @param bool $mergeDeep 562 | * @param array $array1 563 | * @param array $array2 564 | * @return array 565 | */ 566 | public static function mergeExtraArray($mergeDeep, $array1, $array2) 567 | { 568 | if ($mergeDeep) { 569 | return NestedArray::mergeDeep($array1, $array2); 570 | } 571 | 572 | return array_merge($array1, $array2); 573 | } 574 | 575 | /** 576 | * Update Links with a 'self.version' constraint with the root package's 577 | * version. 578 | * 579 | * @param string $type Link type 580 | * @param array $links 581 | * @param RootPackageInterface $root 582 | * @return array 583 | */ 584 | protected function replaceSelfVersionDependencies( 585 | $type, 586 | array $links, 587 | RootPackageInterface $root 588 | ) { 589 | $linkType = BasePackage::$supportedLinkTypes[$type]; 590 | $version = $root->getVersion(); 591 | $prettyVersion = $root->getPrettyVersion(); 592 | $vp = $this->versionParser; 593 | 594 | $method = 'get' . ucfirst($linkType['method']); 595 | $packages = $root->$method(); 596 | 597 | return array_map( 598 | static function ($link) use ($linkType, $version, $prettyVersion, $vp, $packages) { 599 | if ('self.version' === $link->getPrettyConstraint()) { 600 | if (isset($packages[$link->getSource()])) { 601 | /** @var Link $package */ 602 | $package = $packages[$link->getSource()]; 603 | return new Link( 604 | $link->getSource(), 605 | $link->getTarget(), 606 | $vp->parseConstraints($package->getConstraint()->getPrettyString()), 607 | $linkType['description'], 608 | $package->getPrettyConstraint() 609 | ); 610 | } 611 | 612 | return new Link( 613 | $link->getSource(), 614 | $link->getTarget(), 615 | $vp->parseConstraints($version), 616 | $linkType['description'], 617 | $prettyVersion 618 | ); 619 | } 620 | return $link; 621 | }, 622 | $links 623 | ); 624 | } 625 | 626 | /** 627 | * Get a full featured Package from a RootPackageInterface. 628 | * 629 | * In Composer versions before 599ad77 the RootPackageInterface only 630 | * defines a sub-set of operations needed by composer-merge-plugin and 631 | * RootAliasPackage only implemented those methods defined by the 632 | * interface. Most of the unimplemented methods in RootAliasPackage can be 633 | * worked around because the getter methods that are implemented proxy to 634 | * the aliased package which we can modify by unwrapping. The exception 635 | * being modifying the 'conflicts', 'provides' and 'replaces' collections. 636 | * We have no way to actually modify those collections unfortunately in 637 | * older versions of Composer. 638 | * 639 | * @param RootPackageInterface $root 640 | * @param string $method Method needed 641 | * @return RootPackageInterface|RootPackage 642 | */ 643 | public static function unwrapIfNeeded( 644 | RootPackageInterface $root, 645 | $method = 'setExtra' 646 | ) { 647 | // @codeCoverageIgnoreStart 648 | if ($root instanceof RootAliasPackage && 649 | !method_exists($root, $method) 650 | ) { 651 | // Unwrap and return the aliased RootPackage. 652 | $root = $root->getAliasOf(); 653 | } 654 | // @codeCoverageIgnoreEnd 655 | return $root; 656 | } 657 | 658 | protected function mergeAliases(RootPackageInterface $root) 659 | { 660 | $aliases = []; 661 | $unwrapped = self::unwrapIfNeeded($root, 'setAliases'); 662 | foreach (array('require', 'require-dev') as $linkType) { 663 | $linkInfo = BasePackage::$supportedLinkTypes[$linkType]; 664 | $method = 'get'.ucfirst($linkInfo['method']); 665 | $links = []; 666 | foreach ($unwrapped->$method() as $link) { 667 | $links[$link->getTarget()] = $link->getConstraint()->getPrettyString(); 668 | } 669 | $aliases = $this->extractAliases($links, $aliases); 670 | } 671 | $unwrapped->setAliases($aliases); 672 | } 673 | 674 | /** 675 | * Extract aliases from version constraints (dev-branch as 1.0.0). 676 | * 677 | * @param array $requires 678 | * @param array $aliases 679 | * @return array 680 | * @see RootPackageLoader::extractAliases() 681 | */ 682 | protected function extractAliases(array $requires, array $aliases) 683 | { 684 | foreach ($requires as $reqName => $reqVersion) { 685 | if (preg_match('{^([^,\s#]+)(?:#[^ ]+)? +as +([^,\s]+)$}', $reqVersion, $match)) { 686 | $aliases[] = [ 687 | 'package' => strtolower($reqName), 688 | 'version' => $this->versionParser->normalize($match[1], $reqVersion), 689 | 'alias' => $match[2], 690 | 'alias_normalized' => $this->versionParser->normalize($match[2], $reqVersion), 691 | ]; 692 | } elseif (strpos($reqVersion, ' as ') !== false) { 693 | throw new UnexpectedValueException( 694 | 'Invalid alias definition in "'.$reqName.'": "'.$reqVersion.'". ' 695 | . 'Aliases should be in the form "exact-version as other-exact-version".' 696 | ); 697 | } 698 | } 699 | 700 | return $aliases; 701 | } 702 | 703 | /** 704 | * Update the root packages reference information. 705 | * 706 | * @param RootPackageInterface $root 707 | */ 708 | protected function mergeReferences(RootPackageInterface $root) 709 | { 710 | // Merge source reference information for merged packages. 711 | // @see RootPackageLoader::load 712 | $references = []; 713 | $unwrapped = self::unwrapIfNeeded($root, 'setReferences'); 714 | foreach (['require', 'require-dev'] as $linkType) { 715 | $linkInfo = BasePackage::$supportedLinkTypes[$linkType]; 716 | $method = 'get'.ucfirst($linkInfo['method']); 717 | $links = []; 718 | foreach ($unwrapped->$method() as $link) { 719 | $links[$link->getTarget()] = $link->getConstraint()->getPrettyString(); 720 | } 721 | $references = $this->extractReferences($links, $references); 722 | } 723 | $unwrapped->setReferences($references); 724 | } 725 | 726 | /** 727 | * Extract vcs revision from version constraint (dev-master#abc123. 728 | * 729 | * @param array $requires 730 | * @param array $references 731 | * @return array 732 | * @see RootPackageLoader::extractReferences() 733 | */ 734 | protected function extractReferences(array $requires, array $references) 735 | { 736 | foreach ($requires as $reqName => $reqVersion) { 737 | $reqVersion = preg_replace('{^([^,\s@]+) as .+$}', '$1', $reqVersion); 738 | $stabilityName = VersionParser::parseStability($reqVersion); 739 | if ($stabilityName === 'dev' && 740 | preg_match('{^[^,\s@]+?#([a-f0-9]+)$}', $reqVersion, $match) 741 | ) { 742 | $name = strtolower($reqName); 743 | $references[$name] = $match[1]; 744 | } 745 | } 746 | 747 | return $references; 748 | } 749 | } 750 | // vim:sw=4:ts=4:sts=4:et: 751 | -------------------------------------------------------------------------------- /src/Logger.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class Logger 21 | { 22 | /** 23 | * @var string $name 24 | */ 25 | protected $name; 26 | 27 | /** 28 | * @var IOInterface $inputOutput 29 | */ 30 | protected $inputOutput; 31 | 32 | /** 33 | * @param string $name 34 | * @param IOInterface $io 35 | */ 36 | public function __construct($name, IOInterface $io) 37 | { 38 | $this->name = $name; 39 | $this->inputOutput = $io; 40 | } 41 | 42 | /** 43 | * Log a debug message 44 | * 45 | * Messages will be output at the "very verbose" logging level (eg `-vv` 46 | * needed on the Composer command). 47 | * 48 | * @param string $message 49 | */ 50 | public function debug($message) 51 | { 52 | if ($this->inputOutput->isVeryVerbose()) { 53 | $message = " [{$this->name}] {$message}"; 54 | $this->log($message); 55 | } 56 | } 57 | 58 | /** 59 | * Log an informative message 60 | * 61 | * Messages will be output at the "verbose" logging level (eg `-v` needed 62 | * on the Composer command). 63 | * 64 | * @param string $message 65 | */ 66 | public function info($message) 67 | { 68 | if ($this->inputOutput->isVerbose()) { 69 | $message = " [{$this->name}] {$message}"; 70 | $this->log($message); 71 | } 72 | } 73 | 74 | /** 75 | * Log a warning message 76 | * 77 | * @param string $message 78 | */ 79 | public function warning($message) 80 | { 81 | $message = " [{$this->name}] {$message}"; 82 | $this->log($message); 83 | } 84 | 85 | /** 86 | * Write a message 87 | * 88 | * @param string $message 89 | */ 90 | public function log($message) 91 | { 92 | if (method_exists($this->inputOutput, 'writeError')) { 93 | $this->inputOutput->writeError($message); 94 | } else { 95 | // @codeCoverageIgnoreStart 96 | // Backwards compatibility for Composer before cb336a5 97 | $this->inputOutput->write($message); 98 | // @codeCoverageIgnoreEnd 99 | } 100 | } 101 | } 102 | // vim:sw=4:ts=4:sts=4:et: 103 | -------------------------------------------------------------------------------- /src/MergePlugin.php: -------------------------------------------------------------------------------- 1 | 76 | */ 77 | class MergePlugin implements PluginInterface, EventSubscriberInterface 78 | { 79 | 80 | /** 81 | * Official package name 82 | */ 83 | public const PACKAGE_NAME = 'wikimedia/composer-merge-plugin'; 84 | 85 | /** 86 | * Priority that plugin uses to register callbacks. 87 | */ 88 | private const CALLBACK_PRIORITY = 50000; 89 | 90 | /** 91 | * @var Composer $composer 92 | */ 93 | protected $composer; 94 | 95 | /** 96 | * @var PluginState $state 97 | */ 98 | protected $state; 99 | 100 | /** 101 | * @var Logger $logger 102 | */ 103 | protected $logger; 104 | 105 | /** 106 | * Files that have already been fully processed 107 | * 108 | * @var array $loaded 109 | */ 110 | protected $loaded = []; 111 | 112 | /** 113 | * Files that have already been partially processed 114 | * 115 | * @var array $loadedNoDev 116 | */ 117 | protected $loadedNoDev = []; 118 | 119 | /** 120 | * Nested packages to restrict update operations. 121 | * 122 | * @var array $updateAllowList 123 | */ 124 | protected $updateAllowList = []; 125 | 126 | /** 127 | * {@inheritdoc} 128 | */ 129 | public function activate(Composer $composer, IOInterface $io) 130 | { 131 | $this->composer = $composer; 132 | $this->state = new PluginState($this->composer); 133 | $this->logger = new Logger('merge-plugin', $io); 134 | } 135 | 136 | /** 137 | * {@inheritdoc} 138 | */ 139 | public function deactivate(Composer $composer, IOInterface $io) 140 | { 141 | } 142 | 143 | /** 144 | * {@inheritdoc} 145 | */ 146 | public function uninstall(Composer $composer, IOInterface $io) 147 | { 148 | } 149 | 150 | /** 151 | * {@inheritdoc} 152 | */ 153 | public static function getSubscribedEvents() 154 | { 155 | return [ 156 | PluginEvents::INIT => 157 | ['onInit', self::CALLBACK_PRIORITY], 158 | PackageEvents::POST_PACKAGE_INSTALL => 159 | ['onPostPackageInstall', self::CALLBACK_PRIORITY], 160 | ScriptEvents::POST_INSTALL_CMD => 161 | ['onPostInstallOrUpdate', self::CALLBACK_PRIORITY], 162 | ScriptEvents::POST_UPDATE_CMD => 163 | ['onPostInstallOrUpdate', self::CALLBACK_PRIORITY], 164 | ScriptEvents::PRE_AUTOLOAD_DUMP => 165 | ['onInstallUpdateOrDump', self::CALLBACK_PRIORITY], 166 | ScriptEvents::PRE_INSTALL_CMD => 167 | ['onInstallUpdateOrDump', self::CALLBACK_PRIORITY], 168 | ScriptEvents::PRE_UPDATE_CMD => 169 | ['onInstallUpdateOrDump', self::CALLBACK_PRIORITY], 170 | ]; 171 | } 172 | 173 | /** 174 | * Get list of packages to restrict update operations. 175 | * 176 | * @return string[] 177 | * @see \Composer\Installer::setUpdateAllowList() 178 | */ 179 | public function getUpdateAllowList() 180 | { 181 | return array_keys($this->updateAllowList); 182 | } 183 | 184 | /** 185 | * Handle an event callback for initialization. 186 | * 187 | * @param BaseEvent $event 188 | */ 189 | public function onInit(BaseEvent $event) 190 | { 191 | $this->state->loadSettings(); 192 | // It is not possible to know if the user specified --dev or --no-dev 193 | // so assume it is false. The dev section will be merged later when 194 | // the other events fire. 195 | $this->state->setDevMode(false); 196 | $this->mergeFiles($this->state->getIncludes(), false); 197 | $this->mergeFiles($this->state->getRequires(), true); 198 | } 199 | 200 | /** 201 | * Handle an event callback for an install, update or dump command by 202 | * checking for "merge-plugin" in the "extra" data and merging package 203 | * contents if found. 204 | * 205 | * @param ScriptEvent $event 206 | */ 207 | public function onInstallUpdateOrDump(ScriptEvent $event) 208 | { 209 | $this->state->loadSettings(); 210 | $this->state->setDevMode($event->isDevMode()); 211 | $this->mergeFiles($this->state->getIncludes(), false); 212 | $this->mergeFiles($this->state->getRequires(), true); 213 | 214 | if ($event->getName() === ScriptEvents::PRE_AUTOLOAD_DUMP) { 215 | $this->state->setDumpAutoloader(true); 216 | $flags = $event->getFlags(); 217 | if (isset($flags['optimize'])) { 218 | $this->state->setOptimizeAutoloader($flags['optimize']); 219 | } 220 | } 221 | } 222 | 223 | /** 224 | * Find configuration files matching the configured glob patterns and 225 | * merge their contents with the master package. 226 | * 227 | * @param array $patterns List of files/glob patterns 228 | * @param bool $required Are the patterns required to match files? 229 | * @throws MissingFileException when required and a pattern returns no 230 | * results 231 | */ 232 | protected function mergeFiles(array $patterns, $required = false) 233 | { 234 | $root = $this->composer->getPackage(); 235 | 236 | $files = array_map( 237 | static function ($files, $pattern) use ($required) { 238 | if ($required && !$files) { 239 | throw new MissingFileException( 240 | "merge-plugin: No files matched required '{$pattern}'" 241 | ); 242 | } 243 | return $files; 244 | }, 245 | array_map('glob', $patterns), 246 | $patterns 247 | ); 248 | 249 | foreach (array_reduce($files, 'array_merge', []) as $path) { 250 | $this->mergeFile($root, $path); 251 | } 252 | } 253 | 254 | /** 255 | * Read a JSON file and merge its contents 256 | * 257 | * @param RootPackageInterface $root 258 | * @param string $path 259 | */ 260 | protected function mergeFile(RootPackageInterface $root, $path) 261 | { 262 | if (isset($this->loaded[$path]) || 263 | (isset($this->loadedNoDev[$path]) && !$this->state->isDevMode()) 264 | ) { 265 | $this->logger->debug( 266 | "Already merged $path completely" 267 | ); 268 | return; 269 | } 270 | 271 | $package = new ExtraPackage($path, $this->composer, $this->logger); 272 | 273 | if (isset($this->loadedNoDev[$path])) { 274 | $this->logger->info( 275 | "Loading -dev sections of {$path}..." 276 | ); 277 | $package->mergeDevInto($root, $this->state); 278 | } else { 279 | $this->logger->info("Loading {$path}..."); 280 | $package->mergeInto($root, $this->state); 281 | } 282 | 283 | $requirements = $package->getMergedRequirements(); 284 | if (!empty($requirements)) { 285 | $this->updateAllowList = array_replace( 286 | $this->updateAllowList, 287 | array_fill_keys($requirements, true) 288 | ); 289 | } 290 | 291 | if ($this->state->isDevMode()) { 292 | $this->loaded[$path] = true; 293 | } else { 294 | $this->loadedNoDev[$path] = true; 295 | } 296 | 297 | if ($this->state->recurseIncludes()) { 298 | $this->mergeFiles($package->getIncludes(), false); 299 | $this->mergeFiles($package->getRequires(), true); 300 | } 301 | } 302 | 303 | /** 304 | * Handle an event callback following installation of a new package by 305 | * checking to see if the package that was installed was our plugin. 306 | * 307 | * @param PackageEvent $event 308 | */ 309 | public function onPostPackageInstall(PackageEvent $event) 310 | { 311 | $op = $event->getOperation(); 312 | if ($op instanceof InstallOperation) { 313 | $package = $op->getPackage()->getName(); 314 | if ($package === self::PACKAGE_NAME) { 315 | $this->logger->info('composer-merge-plugin installed'); 316 | $this->state->setFirstInstall(true); 317 | $this->state->setLocked( 318 | $event->getComposer()->getLocker()->isLocked() 319 | ); 320 | } 321 | } 322 | } 323 | 324 | /** 325 | * Handle an event callback following an install or update command. If our 326 | * plugin was installed during the run then trigger an update command to 327 | * process any merge-patterns in the current config. 328 | * 329 | * @param ScriptEvent $event 330 | */ 331 | public function onPostInstallOrUpdate(ScriptEvent $event) 332 | { 333 | // @codeCoverageIgnoreStart 334 | if ($this->state->isFirstInstall()) { 335 | $this->state->setFirstInstall(false); 336 | 337 | $requirements = $this->getUpdateAllowList(); 338 | if (empty($requirements)) { 339 | return; 340 | } 341 | 342 | $this->logger->log("\n".'Running composer update to apply merge settings'); 343 | 344 | $lockBackup = null; 345 | $lock = null; 346 | if (!$this->state->isComposer1()) { 347 | $file = Factory::getComposerFile(); 348 | $lock = Factory::getLockFile($file); 349 | if (file_exists($lock)) { 350 | $lockBackup = file_get_contents($lock); 351 | } 352 | } 353 | 354 | $config = $this->composer->getConfig(); 355 | $preferSource = $config->get('preferred-install') === 'source'; 356 | $preferDist = $config->get('preferred-install') === 'dist'; 357 | 358 | $installer = Installer::create( 359 | $event->getIO(), 360 | // Create a new Composer instance to ensure full processing of 361 | // the merged files. 362 | Factory::create($event->getIO(), null, false) 363 | ); 364 | 365 | $installer->setPreferSource($preferSource); 366 | $installer->setPreferDist($preferDist); 367 | $installer->setDevMode($event->isDevMode()); 368 | $installer->setDumpAutoloader($this->state->shouldDumpAutoloader()); 369 | $installer->setOptimizeAutoloader( 370 | $this->state->shouldOptimizeAutoloader() 371 | ); 372 | 373 | $installer->setUpdate(true); 374 | 375 | if ($this->state->isComposer1()) { 376 | // setUpdateWhitelist() only exists in composer 1.x. Configure as to run phan against composer 2.x 377 | // @phan-suppress-next-line PhanUndeclaredMethod 378 | $installer->setUpdateWhitelist($requirements); 379 | } else { 380 | $installer->setUpdateAllowList($requirements); 381 | } 382 | 383 | $status = $installer->run(); 384 | if (( $status !== 0 ) && $lockBackup && $lock && !$this->state->isComposer1()) { 385 | $this->logger->log( 386 | "\n".''. 387 | 'Update to apply merge settings failed, reverting '.$lock.' to its original content.'. 388 | '' 389 | ); 390 | file_put_contents($lock, $lockBackup); 391 | } 392 | } 393 | // @codeCoverageIgnoreEnd 394 | } 395 | } 396 | // vim:sw=4:ts=4:sts=4:et: 397 | -------------------------------------------------------------------------------- /src/MissingFileException.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class MissingFileException extends RuntimeException 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /src/MultiConstraint.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class MultiConstraint extends SemverMultiConstraint 25 | { 26 | /** 27 | * Tries to optimize the constraints as much as possible, meaning 28 | * reducing/collapsing congruent constraints etc. 29 | * Does not necessarily return a MultiConstraint instance if 30 | * things can be reduced to a simple constraint 31 | * 32 | * @param ConstraintInterface[] $constraints A set of constraints 33 | * @param bool $conjunctive Whether the constraints should be treated as conjunctive or disjunctive 34 | * 35 | * @return ConstraintInterface 36 | */ 37 | public static function create(array $constraints, $conjunctive = true) 38 | { 39 | if (count($constraints) === 0) { 40 | // EmptyConstraint only exists in composer 1.x. Configure as to run phan against composer 2.x 41 | // @phan-suppress-next-line PhanTypeMismatchReturn, PhanUndeclaredClassMethod 42 | return new EmptyConstraint(); 43 | } 44 | 45 | if (count($constraints) === 1) { 46 | return $constraints[0]; 47 | } 48 | 49 | $optimized = self::optimizeConstraints($constraints, $conjunctive); 50 | if ($optimized !== null) { 51 | list($constraints, $conjunctive) = $optimized; 52 | if (count($constraints) === 1) { 53 | return $constraints[0]; 54 | } 55 | } 56 | 57 | return new self($constraints, $conjunctive); 58 | } 59 | 60 | /** 61 | * @return array|null 62 | */ 63 | private static function optimizeConstraints(array $constraints, $conjunctive) 64 | { 65 | // parse the two OR groups and if they are contiguous we collapse 66 | // them into one constraint 67 | // [>= 1 < 2] || [>= 2 < 3] || [>= 3 < 4] => [>= 1 < 4] 68 | if (!$conjunctive) { 69 | $left = $constraints[0]; 70 | $mergedConstraints = []; 71 | $optimized = false; 72 | for ($i = 1, $l = count($constraints); $i < $l; $i++) { 73 | $right = $constraints[$i]; 74 | if ($left instanceof SemverMultiConstraint 75 | && $left->conjunctive 76 | && $right instanceof SemverMultiConstraint 77 | && $right->conjunctive 78 | && count($left->constraints) === 2 79 | && count($right->constraints) === 2 80 | && ($left0 = (string) $left->constraints[0]) 81 | && $left0[0] === '>' && $left0[1] === '=' 82 | && ($left1 = (string) $left->constraints[1]) 83 | && $left1[0] === '<' 84 | && ($right0 = (string) $right->constraints[0]) 85 | && $right0[0] === '>' && $right0[1] === '=' 86 | && ($right1 = (string) $right->constraints[1]) 87 | && $right1[0] === '<' 88 | && substr($left1, 2) === substr($right0, 3) 89 | ) { 90 | $optimized = true; 91 | $left = new MultiConstraint( 92 | [ 93 | $left->constraints[0], 94 | $right->constraints[1], 95 | ], 96 | true 97 | ); 98 | } else { 99 | $mergedConstraints[] = $left; 100 | $left = $right; 101 | } 102 | } 103 | if ($optimized) { 104 | $mergedConstraints[] = $left; 105 | return [$mergedConstraints, false]; 106 | } 107 | } 108 | 109 | // TODO: Here's the place to put more optimizations 110 | 111 | return null; 112 | } 113 | } 114 | // vim:sw=4:ts=4:sts=4:et: 115 | -------------------------------------------------------------------------------- /src/NestedArray.php: -------------------------------------------------------------------------------- 1 | 'x', 'attributes' => ['title' => t('X'), 'class' => ['a', 'b']]]; 33 | * $link_options_2 = ['fragment' => 'y', 'attributes' => ['title' => t('Y'), 'class' => ['c', 'd']]]; 34 | * 35 | * // This results in ['fragment' => ['x', 'y'], 'attributes' => 36 | * // ['title' => [t('X'), t('Y')], 'class' => ['a', 'b', 37 | * // 'c', 'd']]]. 38 | * $incorrect = array_merge_recursive($link_options_1, $link_options_2); 39 | * 40 | * // This results in ['fragment' => 'y', 'attributes' => 41 | * // ['title' => t('Y'), 'class' => ['a', 'b', 'c', 'd']]]. 42 | * $correct = NestedArray::mergeDeep($link_options_1, $link_options_2); 43 | * @endcode 44 | * 45 | * @param mixed ...$params Arrays to merge. 46 | * 47 | * @return array The merged array. 48 | * 49 | * @see NestedArray::mergeDeepArray() 50 | */ 51 | public static function mergeDeep(...$params) 52 | { 53 | return self::mergeDeepArray($params); 54 | } 55 | 56 | /** 57 | * Merges multiple arrays, recursively, and returns the merged array. 58 | * 59 | * This function is equivalent to NestedArray::mergeDeep(), except the 60 | * input arrays are passed as a single array parameter rather than 61 | * a variable parameter list. 62 | * 63 | * The following are equivalent: 64 | * - NestedArray::mergeDeep($a, $b); 65 | * - NestedArray::mergeDeepArray([$a, $b]); 66 | * 67 | * The following are also equivalent: 68 | * - call_user_func_array('NestedArray::mergeDeep', $arrays_to_merge); 69 | * - NestedArray::mergeDeepArray($arrays_to_merge); 70 | * 71 | * @param array $arrays 72 | * An arrays of arrays to merge. 73 | * @param bool $preserveIntegerKeys 74 | * (optional) If given, integer keys will be preserved and merged 75 | * instead of appended. Defaults to false. 76 | * 77 | * @return array 78 | * The merged array. 79 | * 80 | * @see NestedArray::mergeDeep() 81 | */ 82 | public static function mergeDeepArray( 83 | array $arrays, 84 | $preserveIntegerKeys = false 85 | ) { 86 | $result = []; 87 | foreach ($arrays as $array) { 88 | foreach ($array as $key => $value) { 89 | // Renumber integer keys as array_merge_recursive() does 90 | // unless $preserveIntegerKeys is set to TRUE. Note that PHP 91 | // automatically converts array keys that are integer strings 92 | // (e.g., '1') to integers. 93 | if (is_int($key) && !$preserveIntegerKeys) { 94 | $result[] = $value; 95 | } elseif (isset($result[$key]) && 96 | is_array($result[$key]) && 97 | is_array($value) 98 | ) { 99 | // Recurse when both values are arrays. 100 | $result[$key] = self::mergeDeepArray( 101 | [$result[$key], $value], 102 | $preserveIntegerKeys 103 | ); 104 | } else { 105 | // Otherwise, use the latter value, overriding any 106 | // previous value. 107 | $result[$key] = $value; 108 | } 109 | } 110 | } 111 | return $result; 112 | } 113 | } 114 | // vim:sw=4:ts=4:sts=4:et: 115 | -------------------------------------------------------------------------------- /src/PluginState.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class PluginState 22 | { 23 | /** 24 | * @var Composer $composer 25 | */ 26 | protected $composer; 27 | 28 | /** 29 | * @var bool $isComposer1 30 | */ 31 | protected $isComposer1; 32 | 33 | /** 34 | * @var array $includes 35 | */ 36 | protected $includes = []; 37 | 38 | /** 39 | * @var array $requires 40 | */ 41 | protected $requires = []; 42 | 43 | /** 44 | * @var bool $devMode 45 | */ 46 | protected $devMode = false; 47 | 48 | /** 49 | * @var bool $recurse 50 | */ 51 | protected $recurse = true; 52 | 53 | /** 54 | * @var bool $replace 55 | */ 56 | protected $replace = false; 57 | 58 | /** 59 | * @var bool $ignore 60 | */ 61 | protected $ignore = false; 62 | 63 | /** 64 | * Whether to merge the -dev sections. 65 | * @var bool $mergeDev 66 | */ 67 | protected $mergeDev = true; 68 | 69 | /** 70 | * Whether to merge the extra section. 71 | * 72 | * By default, the extra section is not merged and there will be many 73 | * cases where the merge of the extra section is performed too late 74 | * to be of use to other plugins. When enabled, merging uses one of 75 | * two strategies - either 'first wins' or 'last wins'. When enabled, 76 | * 'first wins' is the default behaviour. If Replace mode is activated 77 | * then 'last wins' is used. 78 | * 79 | * @var bool $mergeExtra 80 | */ 81 | protected $mergeExtra = false; 82 | 83 | /** 84 | * Whether to merge the extra section in a deep / recursive way. 85 | * 86 | * By default the extra section is merged with array_merge() and duplicate 87 | * keys are ignored. When enabled this allows to merge the arrays recursively 88 | * using the following rule: Integer keys are merged, while array values are 89 | * replaced where the later values overwrite the former. 90 | * 91 | * This is useful especially for the extra section when plugins use larger 92 | * structures like a 'patches' key with the packages as sub-keys and the 93 | * patches as values. 94 | * 95 | * When 'replace' mode is activated the order of array merges is exchanged. 96 | * 97 | * @var bool $mergeExtraDeep 98 | */ 99 | protected $mergeExtraDeep = false; 100 | 101 | /** 102 | * Whether to merge the replace section. 103 | * 104 | * @var bool $mergeReplace 105 | */ 106 | protected $mergeReplace = true; 107 | 108 | /** 109 | * Whether to merge the scripts section. 110 | * 111 | * @var bool $mergeScripts 112 | */ 113 | protected $mergeScripts = false; 114 | 115 | /** 116 | * @var bool $firstInstall 117 | */ 118 | protected $firstInstall = false; 119 | 120 | /** 121 | * @var bool $locked 122 | */ 123 | protected $locked = false; 124 | 125 | /** 126 | * @var bool $dumpAutoloader 127 | */ 128 | protected $dumpAutoloader = false; 129 | 130 | /** 131 | * @var bool $optimizeAutoloader 132 | */ 133 | protected $optimizeAutoloader = false; 134 | 135 | /** 136 | * @param Composer $composer 137 | */ 138 | public function __construct(Composer $composer) 139 | { 140 | $this->composer = $composer; 141 | $this->isComposer1 = version_compare(PluginInterface::PLUGIN_API_VERSION, '2.0.0', '<'); 142 | } 143 | 144 | /** 145 | * Test if this plugin runs within Composer 1. 146 | * 147 | * @return bool 148 | */ 149 | public function isComposer1() 150 | { 151 | return $this->isComposer1; 152 | } 153 | 154 | /** 155 | * Load plugin settings 156 | */ 157 | public function loadSettings() 158 | { 159 | $extra = $this->composer->getPackage()->getExtra(); 160 | $config = array_merge( 161 | [ 162 | 'include' => [], 163 | 'require' => [], 164 | 'recurse' => true, 165 | 'replace' => false, 166 | 'ignore-duplicates' => false, 167 | 'merge-dev' => true, 168 | 'merge-extra' => false, 169 | 'merge-extra-deep' => false, 170 | 'merge-replace' => true, 171 | 'merge-scripts' => false, 172 | ], 173 | $extra['merge-plugin'] ?? [] 174 | ); 175 | 176 | $this->includes = (is_array($config['include'])) ? 177 | $config['include'] : [$config['include']]; 178 | $this->requires = (is_array($config['require'])) ? 179 | $config['require'] : [$config['require']]; 180 | $this->recurse = (bool)$config['recurse']; 181 | $this->replace = (bool)$config['replace']; 182 | $this->ignore = (bool)$config['ignore-duplicates']; 183 | $this->mergeDev = (bool)$config['merge-dev']; 184 | $this->mergeExtra = (bool)$config['merge-extra']; 185 | $this->mergeExtraDeep = (bool)$config['merge-extra-deep']; 186 | $this->mergeReplace = (bool)$config['merge-replace']; 187 | $this->mergeScripts = (bool)$config['merge-scripts']; 188 | } 189 | 190 | /** 191 | * Get list of filenames and/or glob patterns to include 192 | * 193 | * @return array 194 | */ 195 | public function getIncludes() 196 | { 197 | return $this->includes; 198 | } 199 | 200 | /** 201 | * Get list of filenames and/or glob patterns to require 202 | * 203 | * @return array 204 | */ 205 | public function getRequires() 206 | { 207 | return $this->requires; 208 | } 209 | 210 | /** 211 | * Set the first install flag 212 | * 213 | * @param bool $flag 214 | */ 215 | public function setFirstInstall($flag) 216 | { 217 | $this->firstInstall = (bool)$flag; 218 | } 219 | 220 | /** 221 | * Is this the first time that the plugin has been installed? 222 | * 223 | * @return bool 224 | */ 225 | public function isFirstInstall() 226 | { 227 | return $this->firstInstall; 228 | } 229 | 230 | /** 231 | * Set the locked flag 232 | * 233 | * @param bool $flag 234 | */ 235 | public function setLocked($flag) 236 | { 237 | $this->locked = (bool)$flag; 238 | } 239 | 240 | /** 241 | * Was a lockfile present when the plugin was installed? 242 | * 243 | * @return bool 244 | */ 245 | public function isLocked() 246 | { 247 | return $this->locked; 248 | } 249 | 250 | /** 251 | * Should an update be forced? 252 | * 253 | * @return true If packages are not locked 254 | */ 255 | public function forceUpdate() 256 | { 257 | return !$this->locked; 258 | } 259 | 260 | /** 261 | * Set the devMode flag 262 | * 263 | * @param bool $flag 264 | */ 265 | public function setDevMode($flag) 266 | { 267 | $this->devMode = (bool)$flag; 268 | } 269 | 270 | /** 271 | * Should devMode settings be processed? 272 | * 273 | * @return bool 274 | */ 275 | public function isDevMode() 276 | { 277 | return $this->shouldMergeDev() && $this->devMode; 278 | } 279 | 280 | /** 281 | * Should devMode settings be merged? 282 | * 283 | * @return bool 284 | */ 285 | public function shouldMergeDev() 286 | { 287 | return $this->mergeDev; 288 | } 289 | 290 | /** 291 | * Set the dumpAutoloader flag 292 | * 293 | * @param bool $flag 294 | */ 295 | public function setDumpAutoloader($flag) 296 | { 297 | $this->dumpAutoloader = (bool)$flag; 298 | } 299 | 300 | /** 301 | * Is the autoloader file supposed to be written out? 302 | * 303 | * @return bool 304 | */ 305 | public function shouldDumpAutoloader() 306 | { 307 | return $this->dumpAutoloader; 308 | } 309 | 310 | /** 311 | * Set the optimizeAutoloader flag 312 | * 313 | * @param bool $flag 314 | */ 315 | public function setOptimizeAutoloader($flag) 316 | { 317 | $this->optimizeAutoloader = (bool)$flag; 318 | } 319 | 320 | /** 321 | * Should the autoloader be optimized? 322 | * 323 | * @return bool 324 | */ 325 | public function shouldOptimizeAutoloader() 326 | { 327 | return $this->optimizeAutoloader; 328 | } 329 | 330 | /** 331 | * Should includes be recursively processed? 332 | * 333 | * @return bool 334 | */ 335 | public function recurseIncludes() 336 | { 337 | return $this->recurse; 338 | } 339 | 340 | /** 341 | * Should duplicate links be replaced in a 'last definition wins' order? 342 | * 343 | * @return bool 344 | */ 345 | public function replaceDuplicateLinks() 346 | { 347 | return $this->replace; 348 | } 349 | 350 | /** 351 | * Should duplicate links be ignored? 352 | * 353 | * @return bool 354 | */ 355 | public function ignoreDuplicateLinks() 356 | { 357 | return $this->ignore; 358 | } 359 | 360 | /** 361 | * Should the extra section be merged? 362 | * 363 | * By default, the extra section is not merged and there will be many 364 | * cases where the merge of the extra section is performed too late 365 | * to be of use to other plugins. When enabled, merging uses one of 366 | * two strategies - either 'first wins' or 'last wins'. When enabled, 367 | * 'first wins' is the default behaviour. If Replace mode is activated 368 | * then 'last wins' is used. 369 | * 370 | * @return bool 371 | */ 372 | public function shouldMergeExtra() 373 | { 374 | return $this->mergeExtra; 375 | } 376 | 377 | /** 378 | * Should the extra section be merged deep / recursively? 379 | * 380 | * By default the extra section is merged with array_merge() and duplicate 381 | * keys are ignored. When enabled this allows to merge the arrays recursively 382 | * using the following rule: Integer keys are merged, while array values are 383 | * replaced where the later values overwrite the former. 384 | * 385 | * This is useful especially for the extra section when plugins use larger 386 | * structures like a 'patches' key with the packages as sub-keys and the 387 | * patches as values. 388 | * 389 | * When 'replace' mode is activated the order of array merges is exchanged. 390 | * 391 | * @return bool 392 | */ 393 | public function shouldMergeExtraDeep() 394 | { 395 | return $this->mergeExtraDeep; 396 | } 397 | 398 | /** 399 | * Should the replace section be merged? 400 | * 401 | * By default, the replace section is merged. 402 | * 403 | * @return bool 404 | */ 405 | public function shouldMergeReplace() 406 | { 407 | return $this->mergeReplace; 408 | } 409 | 410 | /** 411 | * Should the scripts section be merged? 412 | * 413 | * By default, the scripts section is not merged. 414 | * 415 | * @return bool 416 | */ 417 | public function shouldMergeScripts() 418 | { 419 | return $this->mergeScripts; 420 | } 421 | } 422 | // vim:sw=4:ts=4:sts=4:et: 423 | -------------------------------------------------------------------------------- /src/StabilityFlags.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class StabilityFlags 21 | { 22 | 23 | /** 24 | * @var array Current package name => stability mappings 25 | */ 26 | protected $stabilityFlags; 27 | 28 | /** 29 | * @var int Current default minimum stability 30 | */ 31 | protected $minimumStability; 32 | 33 | /** 34 | * @var string Regex to extract an explicit stability flag (eg '@dev') 35 | */ 36 | protected $explicitStabilityRe; 37 | 38 | /** 39 | * @param array $stabilityFlags Current package name => stability mappings 40 | * @param int|string $minimumStability Current default minimum stability 41 | */ 42 | public function __construct( 43 | array $stabilityFlags = [], 44 | $minimumStability = BasePackage::STABILITY_STABLE 45 | ) { 46 | $this->stabilityFlags = $stabilityFlags; 47 | $this->minimumStability = $this->getStabilityInt((string)$minimumStability); 48 | $this->explicitStabilityRe = '/^[^@]*?@(' . 49 | implode('|', array_keys(BasePackage::$stabilities)) . 50 | ')$/i'; 51 | } 52 | 53 | /** 54 | * Get the stability value for a given string. 55 | * 56 | * @param string $name Stability name 57 | * @return int Stability value 58 | */ 59 | protected function getStabilityInt($name) 60 | { 61 | $name = VersionParser::normalizeStability($name); 62 | return BasePackage::$stabilities[$name] ?? BasePackage::STABILITY_STABLE; 63 | } 64 | 65 | /** 66 | * Extract and merge stability flags from the given collection of 67 | * requires with another collection of stability flags. 68 | * 69 | * @param array $requires New package name => link mappings 70 | * @return array Unified package name => stability mappings 71 | */ 72 | public function extractAll(array $requires) 73 | { 74 | $flags = []; 75 | 76 | foreach ($requires as $name => $link) { 77 | $name = strtolower($name); 78 | $version = $link->getPrettyConstraint(); 79 | 80 | $stability = $this->getExplicitStability($version); 81 | 82 | if ($stability === null) { 83 | $stability = $this->getParsedStability($version); 84 | } 85 | 86 | $flags[$name] = max($stability, $this->getCurrentStability($name)); 87 | } 88 | 89 | // Filter out null stability values 90 | return array_filter($flags, function ($v) { 91 | return $v !== null; 92 | }); 93 | } 94 | 95 | /** 96 | * Extract the most unstable explicit stability (eg '@dev') from a version 97 | * specification. 98 | * 99 | * @param string $version 100 | * @return int|null Stability or null if no explict stability found 101 | */ 102 | protected function getExplicitStability($version) 103 | { 104 | $found = null; 105 | $constraints = $this->splitConstraints($version); 106 | foreach ($constraints as $constraint) { 107 | if (preg_match($this->explicitStabilityRe, $constraint, $match)) { 108 | $stability = $this->getStabilityInt($match[1]); 109 | $found = max($stability, $found); 110 | } 111 | } 112 | return $found; 113 | } 114 | 115 | /** 116 | * Split a version specification into a list of version constraints. 117 | * 118 | * @param string $version 119 | * @return array 120 | */ 121 | protected function splitConstraints($version) 122 | { 123 | $found = []; 124 | $orConstraints = preg_split('/\s*\|\|?\s*/', trim($version)); 125 | foreach ($orConstraints as $constraints) { 126 | $andConstraints = preg_split( 127 | '/(?< ,]) *(?getStabilityInt( 148 | VersionParser::parseStability($version) 149 | ); 150 | 151 | if ($stability === BasePackage::STABILITY_STABLE || 152 | $this->minimumStability > $stability 153 | ) { 154 | // Ignore if 'stable' or more stable than the global 155 | // minimum 156 | $stability = null; 157 | } 158 | 159 | return $stability; 160 | } 161 | 162 | /** 163 | * Get the current stability of a given package. 164 | * 165 | * @param string $name 166 | * @return int|null Stability of null if not set 167 | */ 168 | protected function getCurrentStability($name) 169 | { 170 | return $this->stabilityFlags[$name] ?? null; 171 | } 172 | } 173 | // vim:sw=4:ts=4:sts=4:et: 174 | --------------------------------------------------------------------------------