Completed
Pull Request — master (#151)
by Patrick D
23:18 queued 21:22
created

MergePlugin::mergeFiles()   B

Complexity

Conditions 6
Paths 8

Size

Total Lines 29
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 28.1085

Importance

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