Completed
Push — 4 ( d2cbf5...ae5aa4 )
by Maxime
05:55 queued 05:46
created

Module::getRelativeResourcePath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 1
dl 0
loc 6
rs 10
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 RuntimeException;
9
use Serializable;
10
use SilverStripe\Core\Path;
11
use SilverStripe\Dev\Deprecation;
12
13
/**
14
 * Abstraction of a PHP Package. Can be used to retrieve information about Silverstripe CMS modules, and other packages
15
 * managed via composer, by reading their `composer.json` file.
16
 */
17
class Module implements Serializable
18
{
19
    /**
20
     * @deprecated 4.1.0:5.0.0 Use Path::normalise() instead
21
     */
22
    const TRIM_CHARS = ' /\\';
23
24
    /**
25
     * Return value of getCIConfig() when module uses PHPUNit 9
26
     */
27
    const CI_PHPUNIT_NINE = 'CI_PHPUNIT_NINE';
28
29
    /**
30
     * Return value of getCIConfig() when module uses PHPUNit 5
31
     */
32
    const CI_PHPUNIT_FIVE = 'CI_PHPUNIT_FIVE';
33
34
    /**
35
     * Return value of getCIConfig() when module does not use any CI
36
     */
37
    const CI_UNKNOWN = 'CI_UNKNOWN';
38
39
40
41
    /**
42
     * Full directory path to this module with no trailing slash
43
     *
44
     * @var string
45
     */
46
    protected $path = null;
47
48
    /**
49
     * Base folder of application with no trailing slash
50
     *
51
     * @var string
52
     */
53
    protected $basePath = null;
54
55
    /**
56
     * Cache of composer data
57
     *
58
     * @var array
59
     */
60
    protected $composerData = null;
61
62
    /**
63
     * Loaded resources for this module
64
     *
65
     * @var ModuleResource[]
66
     */
67
    protected $resources = [];
68
69
    /**
70
     * Construct a module
71
     *
72
     * @param string $path Absolute filesystem path to this module
73
     * @param string $basePath base path for the application this module is installed in
74
     */
75
    public function __construct($path, $basePath)
76
    {
77
        $this->path = Path::normalise($path);
78
        $this->basePath = Path::normalise($basePath);
79
        $this->loadComposer();
80
    }
81
82
    /**
83
     * Gets name of this module. Used as unique key and identifier for this module.
84
     *
85
     * If installed by composer, this will be the full composer name (vendor/name).
86
     * If not installed by composer this will default to the `basedir()`
87
     *
88
     * @return string
89
     */
90
    public function getName()
91
    {
92
        return $this->getComposerName() ?: $this->getShortName();
93
    }
94
95
    /**
96
     * Get full composer name. Will be `null` if no composer.json is available
97
     *
98
     * @return string|null
99
     */
100
    public function getComposerName()
101
    {
102
        if (isset($this->composerData['name'])) {
103
            return $this->composerData['name'];
104
        }
105
        return null;
106
    }
107
108
    /**
109
     * Get list of folders that need to be made available
110
     *
111
     * @return array
112
     */
113
    public function getExposedFolders()
114
    {
115
        if (isset($this->composerData['extra']['expose'])) {
116
            return $this->composerData['extra']['expose'];
117
        }
118
        return [];
119
    }
120
121
    /**
122
     * Gets "short" name of this module. This is the base directory this module
123
     * is installed in.
124
     *
125
     * If installed in root, this will be generated from the composer name instead
126
     *
127
     * @return string
128
     */
129
    public function getShortName()
130
    {
131
        // If installed in the root directory we need to infer from composer
132
        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...
133
            // Sometimes we customise installer name
134
            if (isset($this->composerData['extra']['installer-name'])) {
135
                return $this->composerData['extra']['installer-name'];
136
            }
137
138
            // Strip from full composer name
139
            $composerName = $this->getComposerName();
140
            if ($composerName) {
141
                list(, $name) = explode('/', $composerName);
142
                return $name;
143
            }
144
        }
145
146
        // Base name of directory
147
        return basename($this->path);
148
    }
149
150
    /**
151
     * Name of the resource directory where vendor resources should be exposed as defined by the `extra.resources-dir`
152
     * key in the composer file. A blank string will be returned if the key is undefined.
153
     *
154
     * Only applicable when reading the composer file for the main project.
155
     * @return string
156
     */
157
    public function getResourcesDir()
158
    {
159
        return isset($this->composerData['extra']['resources-dir'])
160
            ? $this->composerData['extra']['resources-dir']
161
            : '';
162
    }
163
164
    /**
165
     * Get base path for this module
166
     *
167
     * @return string Path with no trailing slash E.g. /var/www/module
168
     */
169
    public function getPath()
170
    {
171
        return $this->path;
172
    }
173
174
    /**
175
     * Get path relative to base dir.
176
     * If module path is base this will be empty string
177
     *
178
     * @return string Path with trimmed slashes. E.g. vendor/silverstripe/module.
179
     */
180
    public function getRelativePath()
181
    {
182
        if ($this->path === $this->basePath) {
183
            return '';
184
        }
185
        return substr($this->path, strlen($this->basePath) + 1);
186
    }
187
188
    public function serialize()
189
    {
190
        return json_encode([$this->path, $this->basePath, $this->composerData]);
191
    }
192
193
    public function unserialize($serialized)
194
    {
195
        list($this->path, $this->basePath, $this->composerData) = json_decode($serialized, true);
196
        $this->resources = [];
197
    }
198
199
    /**
200
     * Activate _config.php for this module, if one exists
201
     */
202
    public function activate()
203
    {
204
        $config = "{$this->path}/_config.php";
205
        if (file_exists($config)) {
206
            requireFile($config);
207
        }
208
    }
209
210
    /**
211
     * @throws Exception
212
     */
213
    protected function loadComposer()
214
    {
215
        // Load composer data
216
        $path = "{$this->path}/composer.json";
217
        if (file_exists($path)) {
218
            $content = file_get_contents($path);
219
            $result = json_decode($content, true);
220
            if (json_last_error()) {
221
                $errorMessage = json_last_error_msg();
222
                throw new Exception("$path: $errorMessage");
223
            }
224
            $this->composerData = $result;
225
        }
226
    }
227
228
    /**
229
     * Get resource for this module
230
     *
231
     * @param string $path
232
     * @return ModuleResource
233
     */
234
    public function getResource($path)
235
    {
236
        $path = Path::normalise($path, true);
237
        if (empty($path)) {
238
            throw new InvalidArgumentException('$path is required');
239
        }
240
        if (isset($this->resources[$path])) {
241
            return $this->resources[$path];
242
        }
243
        return $this->resources[$path] = new ModuleResource($this, $path);
244
    }
245
246
    /**
247
     * @deprecated 4.0.0:5.0.0 Use getResource($path)->getRelativePath() instead
248
     * @param string $path
249
     * @return string
250
     */
251
    public function getRelativeResourcePath($path)
252
    {
253
        Deprecation::notice('5.0', 'Use getResource($path)->getRelativePath() instead');
254
        return $this
255
            ->getResource($path)
256
            ->getRelativePath();
257
    }
258
259
    /**
260
     * @deprecated 4.0.0:5.0.0 Use ->getResource($path)->getPath() instead
261
     * @param string $path
262
     * @return string
263
     */
264
    public function getResourcePath($path)
265
    {
266
        Deprecation::notice('5.0', 'Use getResource($path)->getPath() instead');
267
        return $this
268
            ->getResource($path)
269
            ->getPath();
270
    }
271
272
    /**
273
     * @deprecated 4.0.0:5.0.0 Use ->getResource($path)->getURL() instead
274
     * @param string $path
275
     * @return string
276
     */
277
    public function getResourceURL($path)
278
    {
279
        Deprecation::notice('5.0', 'Use getResource($path)->getURL() instead');
280
        return $this
281
            ->getResource($path)
282
            ->getURL();
283
    }
284
285
    /**
286
     * @deprecated 4.0.0:5.0.0 Use ->getResource($path)->exists() instead
287
     * @param string $path
288
     * @return string
289
     */
290
    public function hasResource($path)
291
    {
292
        Deprecation::notice('5.0', 'Use getResource($path)->exists() instead');
293
        return $this
294
            ->getResource($path)
295
            ->exists();
296
    }
297
298
    /**
299
     * Determine what configurations the module is using to run various aspects of its CI. THe only aspect
300
     * that is observed is `PHP`
301
     * @return array List of configuration aspects e.g.: `['PHP' => 'CI_PHPUNIT_NINE']`
302
     * @internal
303
     */
304
    public function getCIConfig(): array
305
    {
306
        return [
307
            'PHP' => $this->getPhpCiConfig()
308
        ];
309
    }
310
311
    /**
312
     * Determine what CI Configuration the module uses to test its PHP code.
313
     */
314
    private function getPhpCiConfig(): string
315
    {
316
        // We don't have any composer data at all
317
        if (empty($this->composerData)) {
318
            return self::CI_UNKNOWN;
319
        }
320
321
        // We don't have any dev dependencies
322
        if (empty($this->composerData['require-dev']) || !is_array($this->composerData['require-dev'])) {
323
            return self::CI_UNKNOWN;
324
        }
325
326
        // We are assuming a typical setup where the CI lib is defined in require-dev rather than require
327
        $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...
328
329
        // Try to pick which CI we are using based on phpunit constraint
330
        $phpUnitConstraint = $this->requireDevConstraint(['sminnee/phpunit', 'phpunit/phpunit']);
331
        if ($phpUnitConstraint) {
332
            if ($this->constraintSatisfies(
333
                $phpUnitConstraint,
334
                ['5.7.0', '5.0.0', '5.x-dev', '5.7.x-dev'],
335
                5
336
            )) {
337
                return self::CI_PHPUNIT_FIVE;
338
            }
339
            if ($this->constraintSatisfies(
340
                $phpUnitConstraint,
341
                ['9.0.0', '9.5.0', '9.x-dev', '9.5.x-dev'],
342
                9
343
            )) {
344
                return self::CI_PHPUNIT_NINE;
345
            }
346
        }
347
348
        // Try to pick which CI we are using based on recipe-testing constraint
349
        $recipeTestingConstraint = $this->requireDevConstraint(['silverstripe/recipe-testing']);
350
        if ($recipeTestingConstraint) {
351
            if ($this->constraintSatisfies(
352
                $recipeTestingConstraint,
353
                ['1.0.0', '1.1.0', '1.2.0', '1.1.x-dev', '1.2.x-dev', '1.x-dev'],
354
                1
355
            )) {
356
                return self::CI_PHPUNIT_FIVE;
357
            }
358
            if ($this->constraintSatisfies(
359
                $recipeTestingConstraint,
360
                ['2.0.0', '2.0.x-dev', '2.x-dev'],
361
                2
362
            )) {
363
                return self::CI_PHPUNIT_NINE;
364
            }
365
        }
366
367
        return self::CI_UNKNOWN;
368
    }
369
370
    /**
371
     * Retrieve the constraint for the first module that is found in the require-dev section
372
     * @param string[] $modules
373
     * @return false|string
374
     */
375
    private function requireDevConstraint(array $modules)
376
    {
377
        if (empty($this->composerData['require-dev']) || !is_array($this->composerData['require-dev'])) {
378
            return false;
379
        }
380
381
        $requireDev = $this->composerData['require-dev'];
382
        foreach ($modules as $module) {
383
            if (isset($requireDev[$module])) {
384
                return $requireDev[$module];
385
            }
386
        }
387
388
        return false;
389
    }
390
391
    /**
392
     * Determines if the provided constraint allows at least one of the version provided
393
     */
394
    private function constraintSatisfies(
395
        string $constraint,
396
        array $possibleVersions,
397
        int $majorVersionFallback
398
    ): bool {
399
        // Let's see of any of our possible versions is allowed by the constraint
400
        if (!empty(Semver::satisfiedBy($possibleVersions, $constraint))) {
401
            return true;
402
        }
403
404
        // Let's see if we are using an exact version constraint. e.g. ~1.2.3 or 1.2.3 or ~1.2 or 1.2.*
405
        if (preg_match("/^~?$majorVersionFallback(\.(\d+)|\*){0,2}/", $constraint)) {
406
            return true;
407
        }
408
409
        return false;
410
    }
411
}
412
413
/**
414
 * Scope isolated require - prevents access to $this, and prevents module _config.php
415
 * files potentially leaking variables. Required argument $file is commented out
416
 * to avoid leaking that into _config.php
417
 *
418
 * @param string $file
419
 */
420
function requireFile()
421
{
422
    require_once func_get_arg(0);
423
}
424