Passed
Pull Request — 4 (#10150)
by Maxime
06:26
created

Module::requireDevConstraint()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 7
nc 4
nop 1
dl 0
loc 14
rs 9.6111
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Core\Manifest;
4
5
use Composer\Semver\Semver;
6
use Exception;
7
use InvalidArgumentException;
8
use Serializable;
9
use SilverStripe\Core\Path;
10
use SilverStripe\Dev\Deprecation;
11
12
/**
13
 * Abstraction of a PHP Package. Can be used to retrieve information about Silverstripe CMS modules, and other packages
14
 * managed via composer, by reading their `composer.json` file.
15
 */
16
class Module implements Serializable
17
{
18
    /**
19
     * @deprecated 4.1.0:5.0.0 Use Path::normalise() instead
20
     */
21
    const TRIM_CHARS = ' /\\';
22
23
    /**
24
     * Return value of getCILibrary() when module uses PHPUNit 9
25
     */
26
    const CI_PHPUNIT_NINE = 'PHPUnit9';
27
28
    /**
29
     * Return value of getCILibrary() when module uses PHPUNit 5
30
     */
31
    const CI_PHPUNIT_FIVE = 'PHPUnit5';
32
33
    /**
34
     * Return value of getCILibrary() when module does not use any CI
35
     */
36
    const CI_PHPUNIT_UNKNOWN = 'NoPHPUnit';
37
38
39
40
    /**
41
     * Full directory path to this module with no trailing slash
42
     *
43
     * @var string
44
     */
45
    protected $path = null;
46
47
    /**
48
     * Base folder of application with no trailing slash
49
     *
50
     * @var string
51
     */
52
    protected $basePath = null;
53
54
    /**
55
     * Cache of composer data
56
     *
57
     * @var array
58
     */
59
    protected $composerData = null;
60
61
    /**
62
     * Loaded resources for this module
63
     *
64
     * @var ModuleResource[]
65
     */
66
    protected $resources = [];
67
68
    /**
69
     * Construct a module
70
     *
71
     * @param string $path Absolute filesystem path to this module
72
     * @param string $basePath base path for the application this module is installed in
73
     */
74
    public function __construct($path, $basePath)
75
    {
76
        $this->path = Path::normalise($path);
77
        $this->basePath = Path::normalise($basePath);
78
        $this->loadComposer();
79
    }
80
81
    /**
82
     * Gets name of this module. Used as unique key and identifier for this module.
83
     *
84
     * If installed by composer, this will be the full composer name (vendor/name).
85
     * If not installed by composer this will default to the `basedir()`
86
     *
87
     * @return string
88
     */
89
    public function getName()
90
    {
91
        return $this->getComposerName() ?: $this->getShortName();
92
    }
93
94
    /**
95
     * Get full composer name. Will be `null` if no composer.json is available
96
     *
97
     * @return string|null
98
     */
99
    public function getComposerName()
100
    {
101
        if (isset($this->composerData['name'])) {
102
            return $this->composerData['name'];
103
        }
104
        return null;
105
    }
106
107
    /**
108
     * Get list of folders that need to be made available
109
     *
110
     * @return array
111
     */
112
    public function getExposedFolders()
113
    {
114
        if (isset($this->composerData['extra']['expose'])) {
115
            return $this->composerData['extra']['expose'];
116
        }
117
        return [];
118
    }
119
120
    /**
121
     * Gets "short" name of this module. This is the base directory this module
122
     * is installed in.
123
     *
124
     * If installed in root, this will be generated from the composer name instead
125
     *
126
     * @return string
127
     */
128
    public function getShortName()
129
    {
130
        // If installed in the root directory we need to infer from composer
131
        if ($this->path === $this->basePath && $this->composerData) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->composerData of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
132
            // Sometimes we customise installer name
133
            if (isset($this->composerData['extra']['installer-name'])) {
134
                return $this->composerData['extra']['installer-name'];
135
            }
136
137
            // Strip from full composer name
138
            $composerName = $this->getComposerName();
139
            if ($composerName) {
140
                list(, $name) = explode('/', $composerName);
141
                return $name;
142
            }
143
        }
144
145
        // Base name of directory
146
        return basename($this->path);
147
    }
148
149
    /**
150
     * Name of the resource directory where vendor resources should be exposed as defined by the `extra.resources-dir`
151
     * key in the composer file. A blank string will be returned if the key is undefined.
152
     *
153
     * Only applicable when reading the composer file for the main project.
154
     * @return string
155
     */
156
    public function getResourcesDir()
157
    {
158
        return isset($this->composerData['extra']['resources-dir'])
159
            ? $this->composerData['extra']['resources-dir']
160
            : '';
161
    }
162
163
    /**
164
     * Get base path for this module
165
     *
166
     * @return string Path with no trailing slash E.g. /var/www/module
167
     */
168
    public function getPath()
169
    {
170
        return $this->path;
171
    }
172
173
    /**
174
     * Get path relative to base dir.
175
     * If module path is base this will be empty string
176
     *
177
     * @return string Path with trimmed slashes. E.g. vendor/silverstripe/module.
178
     */
179
    public function getRelativePath()
180
    {
181
        if ($this->path === $this->basePath) {
182
            return '';
183
        }
184
        return substr($this->path, strlen($this->basePath) + 1);
185
    }
186
187
    public function serialize()
188
    {
189
        return json_encode([$this->path, $this->basePath, $this->composerData]);
190
    }
191
192
    public function unserialize($serialized)
193
    {
194
        list($this->path, $this->basePath, $this->composerData) = json_decode($serialized, true);
195
        $this->resources = [];
196
    }
197
198
    /**
199
     * Activate _config.php for this module, if one exists
200
     */
201
    public function activate()
202
    {
203
        $config = "{$this->path}/_config.php";
204
        if (file_exists($config)) {
205
            requireFile($config);
206
        }
207
    }
208
209
    /**
210
     * @throws Exception
211
     */
212
    protected function loadComposer()
213
    {
214
        // Load composer data
215
        $path = "{$this->path}/composer.json";
216
        if (file_exists($path)) {
217
            $content = file_get_contents($path);
218
            $result = json_decode($content, true);
219
            if (json_last_error()) {
220
                $errorMessage = json_last_error_msg();
221
                throw new Exception("$path: $errorMessage");
222
            }
223
            $this->composerData = $result;
224
        }
225
    }
226
227
    /**
228
     * Get resource for this module
229
     *
230
     * @param string $path
231
     * @return ModuleResource
232
     */
233
    public function getResource($path)
234
    {
235
        $path = Path::normalise($path, true);
236
        if (empty($path)) {
237
            throw new InvalidArgumentException('$path is required');
238
        }
239
        if (isset($this->resources[$path])) {
240
            return $this->resources[$path];
241
        }
242
        return $this->resources[$path] = new ModuleResource($this, $path);
243
    }
244
245
    /**
246
     * @deprecated 4.0.0:5.0.0 Use getResource($path)->getRelativePath() instead
247
     * @param string $path
248
     * @return string
249
     */
250
    public function getRelativeResourcePath($path)
251
    {
252
        Deprecation::notice('5.0', 'Use getResource($path)->getRelativePath() instead');
253
        return $this
254
            ->getResource($path)
255
            ->getRelativePath();
256
    }
257
258
    /**
259
     * @deprecated 4.0.0:5.0.0 Use ->getResource($path)->getPath() instead
260
     * @param string $path
261
     * @return string
262
     */
263
    public function getResourcePath($path)
264
    {
265
        Deprecation::notice('5.0', 'Use getResource($path)->getPath() instead');
266
        return $this
267
            ->getResource($path)
268
            ->getPath();
269
    }
270
271
    /**
272
     * @deprecated 4.0.0:5.0.0 Use ->getResource($path)->getURL() instead
273
     * @param string $path
274
     * @return string
275
     */
276
    public function getResourceURL($path)
277
    {
278
        Deprecation::notice('5.0', 'Use getResource($path)->getURL() instead');
279
        return $this
280
            ->getResource($path)
281
            ->getURL();
282
    }
283
284
    /**
285
     * @deprecated 4.0.0:5.0.0 Use ->getResource($path)->exists() instead
286
     * @param string $path
287
     * @return string
288
     */
289
    public function hasResource($path)
290
    {
291
        Deprecation::notice('5.0', 'Use getResource($path)->exists() instead');
292
        return $this
293
            ->getResource($path)
294
            ->exists();
295
    }
296
297
    /**
298
     * Determine what CI library the module is using.
299
     * @internal
300
     */
301
    public function getCILibrary(): string
302
    {
303
        // We don't have any composer data at all
304
        if (empty($this->composerData)) {
305
            return self::CI_PHPUNIT_UNKNOWN;
306
        }
307
308
        // We don't have any dev dependencies
309
        if (empty($this->composerData['require-dev']) || !is_array($this->composerData['require-dev'])) {
310
            return self::CI_PHPUNIT_UNKNOWN;
311
        }
312
313
        // We are assuming a typical setup where the CI lib is defined in require-dev rather than require
314
        $requireDev = $this->composerData['require-dev'];
0 ignored issues
show
Unused Code introduced by
The assignment to $requireDev is dead and can be removed.
Loading history...
315
316
        // Try to pick which CI we are using based on phpunit constraint
317
        $phpUnitConstraint = $this->requireDevConstraint(['sminnee/phpunit', 'phpunit/phpunit']);
318
        if ($phpUnitConstraint) {
319
            if ($this->satisfiesAtLeastOne(['5.7.0', '5.0.0', '5.x-dev', '5.7.x-dev'], $phpUnitConstraint)) {
320
                return self::CI_PHPUNIT_FIVE;
321
            }
322
            if ($this->satisfiesAtLeastOne(['9.0.0', '9.5.0', '9.x-dev', '9.5.x-dev'], $phpUnitConstraint)) {
323
                return self::CI_PHPUNIT_NINE;
324
            }
325
        }
326
327
        // Try to pick which CI we are using based on recipe-testing constraint
328
        $recipeTestingConstraint = $this->requireDevConstraint(['silverstripe/recipe-testing']);
329
        if ($recipeTestingConstraint) {
330
            if ($this->satisfiesAtLeastOne(['1.0.0', '1.1.0', '1.2.0', '1.1.x-dev', '1.2.x-dev', '1.x-dev'], $recipeTestingConstraint)) {
331
                return self::CI_PHPUNIT_FIVE;
332
            }
333
            if ($this->satisfiesAtLeastOne(['2.0.0', '2.0.x-dev', '2.x-dev'], $recipeTestingConstraint)) {
334
                return self::CI_PHPUNIT_NINE;
335
            }
336
        }
337
338
        return self::CI_PHPUNIT_UNKNOWN;
339
    }
340
341
    /**
342
     * Retrieve the constraint for the first module that is found in the require-dev section
343
     * @param string[] $modules
344
     * @return false|string
345
     */
346
    private function requireDevConstraint(array $modules)
347
    {
348
        if (empty($this->composerData['require-dev']) || !is_array($this->composerData['require-dev'])) {
349
            return false;
350
        }
351
352
        $requireDev = $this->composerData['require-dev'];
353
        foreach ($modules as $module) {
354
            if (isset($requireDev[$module])) {
355
                return $requireDev[$module];
356
            }
357
        }
358
359
        return false;
360
    }
361
362
    /**
363
     * Determines if the provided constraint allows at least one of the version provided
364
     */
365
    private function satisfiesAtLeastOne(array $versions, string $constraint): bool
366
    {
367
        return !empty(Semver::satisfiedBy($versions, $constraint));
368
    }
369
}
370
371
/**
372
 * Scope isolated require - prevents access to $this, and prevents module _config.php
373
 * files potentially leaking variables. Required argument $file is commented out
374
 * to avoid leaking that into _config.php
375
 *
376
 * @param string $file
377
 */
378
function requireFile()
379
{
380
    require_once func_get_arg(0);
381
}
382