Passed
Pull Request — 4 (#10222)
by Steve
06:18
created

Module::__serialize()   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 0
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 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
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 getCIConfig() when module uses PHPUNit 9
25
     */
26
    const CI_PHPUNIT_NINE = 'CI_PHPUNIT_NINE';
27
28
    /**
29
     * Return value of getCIConfig() when module uses PHPUNit 5
30
     */
31
    const CI_PHPUNIT_FIVE = 'CI_PHPUNIT_FIVE';
32
33
    /**
34
     * Return value of getCIConfig() when module does not use any CI
35
     */
36
    const CI_UNKNOWN = 'CI_UNKNOWN';
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(): array
188
    {
189
        return [
190
            'path' => $this->path,
191
            'basePath' => $this->basePath,
192
            'composerData' => $this->composerData
193
        ];
194
    }
195
196
    public function __unserialize(array $data): void
197
    {
198
            $this->path = $data['path'];
199
            $this->basePath = $data['basePath'];
200
            $this->composerData = $data['composerData'];
201
            $this->resources = [];
202
    }
203
204
    /**
205
     * The __serialize() magic method will be automatically used instead of this
206
     *
207
     * @return string
208
     * @deprecated will be removed in 5.0
209
     */
210
    public function serialize()
211
    {
212
        return json_encode([$this->path, $this->basePath, $this->composerData]);
213
    }
214
215
    /**
216
     * The __unserialize() magic method will be automatically used instead of this almost all the time
217
     * This method will be automatically used if existing serialized data was not saved as an associative array
218
     * and the PHP version used in less than PHP 9.0
219
     *
220
     * @param string $serialized
221
     * @deprecated will be removed in 5.0
222
     */
223
    public function unserialize($serialized)
224
    {
225
        list($this->path, $this->basePath, $this->composerData) = json_decode($serialized, true);
226
        $this->resources = [];
227
    }
228
229
    /**
230
     * Activate _config.php for this module, if one exists
231
     */
232
    public function activate()
233
    {
234
        $config = "{$this->path}/_config.php";
235
        if (file_exists($config)) {
236
            requireFile($config);
237
        }
238
    }
239
240
    /**
241
     * @throws Exception
242
     */
243
    protected function loadComposer()
244
    {
245
        // Load composer data
246
        $path = "{$this->path}/composer.json";
247
        if (file_exists($path)) {
248
            $content = file_get_contents($path);
249
            $result = json_decode($content, true);
250
            if (json_last_error()) {
251
                $errorMessage = json_last_error_msg();
252
                throw new Exception("$path: $errorMessage");
253
            }
254
            $this->composerData = $result;
255
        }
256
    }
257
258
    /**
259
     * Get resource for this module
260
     *
261
     * @param string $path
262
     * @return ModuleResource
263
     */
264
    public function getResource($path)
265
    {
266
        $path = Path::normalise($path, true);
267
        if (empty($path)) {
268
            throw new InvalidArgumentException('$path is required');
269
        }
270
        if (isset($this->resources[$path])) {
271
            return $this->resources[$path];
272
        }
273
        return $this->resources[$path] = new ModuleResource($this, $path);
274
    }
275
276
    /**
277
     * @deprecated 4.0.0:5.0.0 Use getResource($path)->getRelativePath() instead
278
     * @param string $path
279
     * @return string
280
     */
281
    public function getRelativeResourcePath($path)
282
    {
283
        Deprecation::notice('5.0', 'Use getResource($path)->getRelativePath() instead');
284
        return $this
285
            ->getResource($path)
286
            ->getRelativePath();
287
    }
288
289
    /**
290
     * @deprecated 4.0.0:5.0.0 Use ->getResource($path)->getPath() instead
291
     * @param string $path
292
     * @return string
293
     */
294
    public function getResourcePath($path)
295
    {
296
        Deprecation::notice('5.0', 'Use getResource($path)->getPath() instead');
297
        return $this
298
            ->getResource($path)
299
            ->getPath();
300
    }
301
302
    /**
303
     * @deprecated 4.0.0:5.0.0 Use ->getResource($path)->getURL() instead
304
     * @param string $path
305
     * @return string
306
     */
307
    public function getResourceURL($path)
308
    {
309
        Deprecation::notice('5.0', 'Use getResource($path)->getURL() instead');
310
        return $this
311
            ->getResource($path)
312
            ->getURL();
313
    }
314
315
    /**
316
     * @deprecated 4.0.0:5.0.0 Use ->getResource($path)->exists() instead
317
     * @param string $path
318
     * @return string
319
     */
320
    public function hasResource($path)
321
    {
322
        Deprecation::notice('5.0', 'Use getResource($path)->exists() instead');
323
        return $this
324
            ->getResource($path)
325
            ->exists();
326
    }
327
328
    /**
329
     * Determine what configurations the module is using to run various aspects of its CI. THe only aspect
330
     * that is observed is `PHP`
331
     * @return array List of configuration aspects e.g.: `['PHP' => 'CI_PHPUNIT_NINE']`
332
     * @internal
333
     */
334
    public function getCIConfig(): array
335
    {
336
        return [
337
            'PHP' => $this->getPhpCiConfig()
338
        ];
339
    }
340
341
    /**
342
     * Determine what CI Configuration the module uses to test its PHP code.
343
     */
344
    private function getPhpCiConfig(): string
345
    {
346
        // We don't have any composer data at all
347
        if (empty($this->composerData)) {
348
            return self::CI_UNKNOWN;
349
        }
350
351
        // We don't have any dev dependencies
352
        if (empty($this->composerData['require-dev']) || !is_array($this->composerData['require-dev'])) {
353
            return self::CI_UNKNOWN;
354
        }
355
356
        // We are assuming a typical setup where the CI lib is defined in require-dev rather than require
357
        $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...
358
359
        // Try to pick which CI we are using based on phpunit constraint
360
        $phpUnitConstraint = $this->requireDevConstraint(['sminnee/phpunit', 'phpunit/phpunit']);
361
        if ($phpUnitConstraint) {
362
            if ($this->constraintSatisfies(
363
                $phpUnitConstraint,
364
                ['5.7.0', '5.0.0', '5.x-dev', '5.7.x-dev'],
365
                5
366
            )) {
367
                return self::CI_PHPUNIT_FIVE;
368
            }
369
            if ($this->constraintSatisfies(
370
                $phpUnitConstraint,
371
                ['9.0.0', '9.5.0', '9.x-dev', '9.5.x-dev'],
372
                9
373
            )) {
374
                return self::CI_PHPUNIT_NINE;
375
            }
376
        }
377
378
        // Try to pick which CI we are using based on recipe-testing constraint
379
        $recipeTestingConstraint = $this->requireDevConstraint(['silverstripe/recipe-testing']);
380
        if ($recipeTestingConstraint) {
381
            if ($this->constraintSatisfies(
382
                $recipeTestingConstraint,
383
                ['1.0.0', '1.1.0', '1.2.0', '1.1.x-dev', '1.2.x-dev', '1.x-dev'],
384
                1
385
            )) {
386
                return self::CI_PHPUNIT_FIVE;
387
            }
388
            if ($this->constraintSatisfies(
389
                $recipeTestingConstraint,
390
                ['2.0.0', '2.0.x-dev', '2.x-dev'],
391
                2
392
            )) {
393
                return self::CI_PHPUNIT_NINE;
394
            }
395
        }
396
397
        return self::CI_UNKNOWN;
398
    }
399
400
    /**
401
     * Retrieve the constraint for the first module that is found in the require-dev section
402
     * @param string[] $modules
403
     * @return false|string
404
     */
405
    private function requireDevConstraint(array $modules)
406
    {
407
        if (empty($this->composerData['require-dev']) || !is_array($this->composerData['require-dev'])) {
408
            return false;
409
        }
410
411
        $requireDev = $this->composerData['require-dev'];
412
        foreach ($modules as $module) {
413
            if (isset($requireDev[$module])) {
414
                return $requireDev[$module];
415
            }
416
        }
417
418
        return false;
419
    }
420
421
    /**
422
     * Determines if the provided constraint allows at least one of the version provided
423
     */
424
    private function constraintSatisfies(
425
        string $constraint,
426
        array $possibleVersions,
427
        int $majorVersionFallback
428
    ): bool {
429
        // Let's see of any of our possible versions is allowed by the constraint
430
        if (!empty(Semver::satisfiedBy($possibleVersions, $constraint))) {
431
            return true;
432
        }
433
434
        // 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.*
435
        if (preg_match("/^~?$majorVersionFallback(\.(\d+)|\*){0,2}/", $constraint)) {
436
            return true;
437
        }
438
439
        return false;
440
    }
441
}
442
443
/**
444
 * Scope isolated require - prevents access to $this, and prevents module _config.php
445
 * files potentially leaking variables. Required argument $file is commented out
446
 * to avoid leaking that into _config.php
447
 *
448
 * @param string $file
449
 */
450
function requireFile()
451
{
452
    require_once func_get_arg(0);
453
}
454