Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
84.55% |
186 / 220 |
|
60.71% |
17 / 28 |
CRAP | |
0.00% |
0 / 1 |
ExtensionRegistry | |
84.93% |
186 / 219 |
|
60.71% |
17 / 28 |
129.19 | |
0.00% |
0 / 1 |
getInstance | n/a |
0 / 0 |
n/a |
0 / 0 |
3 | |||||
disableForTest | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
enableForTest | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
setCache | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setCheckDevRequires | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
setLoadTestClassesAndNamespaces | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
queue | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
getCache | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
4.07 | |||
makeCacheKey | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
getVaryHash | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
invalidateProcessCache | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
loadFromQueue | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
4 | |||
saveToCache | |
46.67% |
7 / 15 |
|
0.00% |
0 / 1 |
11.46 | |||
getQueue | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
clearQueue | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
finish | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getAbilities | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
buildVersionChecker | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
readFromQueue | |
84.62% |
22 / 26 |
|
0.00% |
0 / 1 |
10.36 | |||
exportExtractedData | |
93.55% |
58 / 62 |
|
0.00% |
0 / 1 |
28.21 | |||
isLoaded | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
getAttribute | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
registerListeners | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
getLazyLoadedAttribute | |
83.33% |
15 / 18 |
|
0.00% |
0 / 1 |
5.12 | |||
setAttributeForTest | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
getAllThings | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
processAutoLoader | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
setSettingsBuilder | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getSettingsBuilder | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Registration; |
4 | |
5 | use AutoLoader; |
6 | use Composer\Semver\Semver; |
7 | use InvalidArgumentException; |
8 | use LogicException; |
9 | use MediaWiki\DomainEvent\DomainEventSource; |
10 | use MediaWiki\DomainEvent\DomainEventSubscriber; |
11 | use MediaWiki\Settings\SettingsBuilder; |
12 | use MediaWiki\Shell\Shell; |
13 | use MediaWiki\ShellDisabledError; |
14 | use MediaWiki\WikiMap\WikiMap; |
15 | use ObjectCacheFactory; |
16 | use RuntimeException; |
17 | use UnexpectedValueException; |
18 | use Wikimedia\ObjectCache\BagOStuff; |
19 | use Wikimedia\ScopedCallback; |
20 | |
21 | /** |
22 | * @defgroup ExtensionRegistry ExtensionRegistry |
23 | * |
24 | * For higher level documentation, see <https://www.mediawiki.org/wiki/Manual:Extension_registration/Architecture>. |
25 | */ |
26 | |
27 | /** |
28 | * Load JSON files, and uses a Processor to extract information. |
29 | * |
30 | * This also adds the extension's classes to the AutoLoader. |
31 | * |
32 | * @ingroup ExtensionRegistry |
33 | * @since 1.25 |
34 | */ |
35 | class ExtensionRegistry implements DomainEventSubscriber { |
36 | |
37 | /** |
38 | * "requires" key that applies to MediaWiki core |
39 | */ |
40 | public const MEDIAWIKI_CORE = 'MediaWiki'; |
41 | |
42 | /** |
43 | * Version of the highest supported manifest version |
44 | * Note: Update MANIFEST_VERSION_MW_VERSION when changing this |
45 | */ |
46 | public const MANIFEST_VERSION = 2; |
47 | |
48 | /** |
49 | * MediaWiki version constraint representing what the current |
50 | * highest MANIFEST_VERSION is supported in |
51 | */ |
52 | public const MANIFEST_VERSION_MW_VERSION = '>= 1.29.0'; |
53 | |
54 | /** |
55 | * Version of the oldest supported manifest version |
56 | */ |
57 | public const OLDEST_MANIFEST_VERSION = 1; |
58 | |
59 | /** |
60 | * Bump whenever the registration cache needs resetting |
61 | */ |
62 | private const CACHE_VERSION = 8; |
63 | |
64 | private const CACHE_EXPIRY = 60 * 60 * 24; |
65 | |
66 | /** |
67 | * Special key that defines the merge strategy |
68 | * |
69 | * @since 1.26 |
70 | */ |
71 | public const MERGE_STRATEGY = '_merge_strategy'; |
72 | |
73 | /** |
74 | * Attributes that should be lazy-loaded |
75 | */ |
76 | private const LAZY_LOADED_ATTRIBUTES = [ |
77 | 'TrackingCategories', |
78 | 'QUnitTestModules', |
79 | 'SkinLessImportPaths', |
80 | ]; |
81 | |
82 | /** |
83 | * Array of loaded things, keyed by name, values are credits information. |
84 | * |
85 | * The keys that the credit info arrays may have is defined |
86 | * by ExtensionProcessor::CREDIT_ATTRIBS (plus a 'path' key that |
87 | * points to the skin or extension JSON file). |
88 | * |
89 | * This info may be accessed via ExtensionRegistry::getAllThings. |
90 | * |
91 | * @var array[] |
92 | */ |
93 | private $loaded = []; |
94 | |
95 | /** |
96 | * List of paths that should be loaded |
97 | * |
98 | * @var int[] |
99 | */ |
100 | protected $queued = []; |
101 | |
102 | /** |
103 | * Whether we are done loading things |
104 | * |
105 | * @var bool |
106 | */ |
107 | private $finished = false; |
108 | |
109 | /** |
110 | * Items in the JSON file that aren't being |
111 | * set as globals |
112 | * |
113 | * @var array |
114 | */ |
115 | protected $attributes = []; |
116 | |
117 | /** |
118 | * Attributes for testing |
119 | * |
120 | * @var array |
121 | */ |
122 | protected $testAttributes = []; |
123 | |
124 | /** |
125 | * Lazy-loaded attributes |
126 | * |
127 | * @var array |
128 | */ |
129 | protected $lazyAttributes = []; |
130 | |
131 | /** |
132 | * The hash of cache-varying options, lazy-initialised |
133 | * |
134 | * @var string|null |
135 | */ |
136 | private $varyHash; |
137 | |
138 | /** |
139 | * Whether to check dev-requires |
140 | * |
141 | * @var bool |
142 | */ |
143 | protected $checkDev = false; |
144 | |
145 | /** |
146 | * Whether test classes and namespaces should be added to the auto loader |
147 | * |
148 | * @var bool |
149 | */ |
150 | protected $loadTestClassesAndNamespaces = false; |
151 | |
152 | /** |
153 | * @var ExtensionRegistry |
154 | */ |
155 | private static $instance; |
156 | |
157 | /** |
158 | * @var ?BagOStuff |
159 | */ |
160 | private $cache = null; |
161 | |
162 | private ?SettingsBuilder $settingsBuilder = null; |
163 | |
164 | private static bool $accessDisabledForUnitTests = false; |
165 | |
166 | /** |
167 | * @codeCoverageIgnore |
168 | * @return ExtensionRegistry |
169 | */ |
170 | public static function getInstance() { |
171 | if ( self::$accessDisabledForUnitTests ) { |
172 | throw new RuntimeException( 'Access is disabled in unit tests' ); |
173 | } |
174 | if ( self::$instance === null ) { |
175 | self::$instance = new self(); |
176 | } |
177 | |
178 | return self::$instance; |
179 | } |
180 | |
181 | /** |
182 | * @internal |
183 | */ |
184 | public static function disableForTest(): void { |
185 | if ( !defined( 'MW_PHPUNIT_TEST' ) ) { |
186 | throw new RuntimeException( 'Can only be called in tests' ); |
187 | } |
188 | self::$accessDisabledForUnitTests = true; |
189 | } |
190 | |
191 | /** |
192 | * @internal |
193 | */ |
194 | public static function enableForTest(): void { |
195 | if ( !defined( 'MW_PHPUNIT_TEST' ) ) { |
196 | throw new RuntimeException( 'Can only be called in tests' ); |
197 | } |
198 | self::$accessDisabledForUnitTests = false; |
199 | } |
200 | |
201 | /** |
202 | * Set the cache to use for extension info. |
203 | * Intended for use during testing. |
204 | * |
205 | * @internal |
206 | * |
207 | * @param BagOStuff $cache |
208 | */ |
209 | public function setCache( BagOStuff $cache ): void { |
210 | $this->cache = $cache; |
211 | } |
212 | |
213 | /** |
214 | * @since 1.34 |
215 | * |
216 | * @param bool $check |
217 | */ |
218 | public function setCheckDevRequires( $check ) { |
219 | $this->checkDev = $check; |
220 | $this->invalidateProcessCache(); |
221 | } |
222 | |
223 | /** |
224 | * Controls if classes and namespaces defined under the keys TestAutoloadClasses and |
225 | * TestAutoloadNamespaces should be added to the autoloader. |
226 | * |
227 | * @since 1.35 |
228 | * |
229 | * @param bool $load |
230 | */ |
231 | public function setLoadTestClassesAndNamespaces( $load ) { |
232 | $this->loadTestClassesAndNamespaces = $load; |
233 | } |
234 | |
235 | /** |
236 | * @param string $path Absolute path to the JSON file |
237 | */ |
238 | public function queue( $path ) { |
239 | global $wgExtensionInfoMTime; |
240 | |
241 | $mtime = $wgExtensionInfoMTime; |
242 | if ( $mtime === false ) { |
243 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
244 | $mtime = @filemtime( $path ); |
245 | // @codeCoverageIgnoreStart |
246 | if ( $mtime === false ) { |
247 | $err = error_get_last(); |
248 | throw new MissingExtensionException( $path, $err['message'] ); |
249 | // @codeCoverageIgnoreEnd |
250 | } |
251 | } |
252 | $this->queued[$path] = $mtime; |
253 | $this->invalidateProcessCache(); |
254 | } |
255 | |
256 | private function getCache(): BagOStuff { |
257 | if ( !$this->cache ) { |
258 | // NOTE: Copy of ObjectCacheFactory::getDefaultKeyspace |
259 | // |
260 | // Can't call MediaWikiServices here, as we must not cause services |
261 | // to be instantiated before extensions have loaded. |
262 | global $wgCachePrefix; |
263 | $keyspace = ( is_string( $wgCachePrefix ) && $wgCachePrefix !== '' ) |
264 | ? $wgCachePrefix |
265 | : WikiMap::getCurrentWikiDbDomain()->getId(); |
266 | |
267 | return ObjectCacheFactory::makeLocalServerCache( $keyspace ); |
268 | } |
269 | |
270 | return $this->cache; |
271 | } |
272 | |
273 | private function makeCacheKey( BagOStuff $cache, $component, ...$extra ) { |
274 | // Allow reusing cached ExtensionRegistry metadata between wikis (T274648) |
275 | return $cache->makeGlobalKey( |
276 | "registration-$component", |
277 | $this->getVaryHash(), |
278 | ...$extra |
279 | ); |
280 | } |
281 | |
282 | /** |
283 | * Get the cache varying hash |
284 | * |
285 | * @return string |
286 | */ |
287 | private function getVaryHash() { |
288 | if ( $this->varyHash === null ) { |
289 | // We vary the cache on the current queue (what will be or already was loaded) |
290 | // plus various versions of stuff for VersionChecker |
291 | $vary = [ |
292 | 'registration' => self::CACHE_VERSION, |
293 | 'mediawiki' => MW_VERSION, |
294 | 'abilities' => $this->getAbilities(), |
295 | 'checkDev' => $this->checkDev, |
296 | 'queue' => $this->queued, |
297 | ]; |
298 | $this->varyHash = md5( json_encode( $vary ) ); |
299 | } |
300 | |
301 | return $this->varyHash; |
302 | } |
303 | |
304 | /** |
305 | * Invalidate the cache of the vary hash and the lazy options. |
306 | */ |
307 | private function invalidateProcessCache() { |
308 | $this->varyHash = null; |
309 | $this->lazyAttributes = []; |
310 | } |
311 | |
312 | public function loadFromQueue() { |
313 | if ( !$this->queued ) { |
314 | return; |
315 | } |
316 | |
317 | if ( $this->finished ) { |
318 | throw new LogicException( |
319 | "The following paths tried to load late: " |
320 | . implode( ', ', array_keys( $this->queued ) ) |
321 | ); |
322 | } |
323 | |
324 | $cache = $this->getCache(); |
325 | // See if this queue is in APC |
326 | $key = $this->makeCacheKey( $cache, 'main' ); |
327 | $data = $cache->get( $key ); |
328 | if ( !$data ) { |
329 | $data = $this->readFromQueue( $this->queued ); |
330 | $this->saveToCache( $cache, $data ); |
331 | } |
332 | $this->exportExtractedData( $data ); |
333 | } |
334 | |
335 | /** |
336 | * Save data in the cache |
337 | * |
338 | * @param BagOStuff $cache |
339 | * @param array $data |
340 | */ |
341 | protected function saveToCache( BagOStuff $cache, array $data ) { |
342 | global $wgDevelopmentWarnings; |
343 | if ( $data['warnings'] && $wgDevelopmentWarnings ) { |
344 | // If warnings were shown, don't cache it |
345 | return; |
346 | } |
347 | $lazy = []; |
348 | // Cache lazy-loaded attributes separately |
349 | foreach ( self::LAZY_LOADED_ATTRIBUTES as $attrib ) { |
350 | if ( isset( $data['attributes'][$attrib] ) ) { |
351 | $lazy[$attrib] = $data['attributes'][$attrib]; |
352 | unset( $data['attributes'][$attrib] ); |
353 | } |
354 | } |
355 | $mainKey = $this->makeCacheKey( $cache, 'main' ); |
356 | $cache->set( $mainKey, $data, self::CACHE_EXPIRY ); |
357 | foreach ( $lazy as $attrib => $value ) { |
358 | $cache->set( |
359 | $this->makeCacheKey( $cache, 'lazy-attrib', $attrib ), |
360 | $value, |
361 | self::CACHE_EXPIRY |
362 | ); |
363 | } |
364 | } |
365 | |
366 | /** |
367 | * Get the current load queue. Not intended to be used |
368 | * outside of the installer. |
369 | * |
370 | * @return int[] Map of extension.json files' modification timestamps keyed by absolute path |
371 | */ |
372 | public function getQueue() { |
373 | return $this->queued; |
374 | } |
375 | |
376 | /** |
377 | * Clear the current load queue. Not intended to be used |
378 | * outside of the installer. |
379 | */ |
380 | public function clearQueue() { |
381 | $this->queued = []; |
382 | $this->invalidateProcessCache(); |
383 | } |
384 | |
385 | /** |
386 | * After this is called, no more extensions can be loaded |
387 | * |
388 | * @since 1.29 |
389 | */ |
390 | public function finish() { |
391 | $this->finished = true; |
392 | } |
393 | |
394 | /** |
395 | * Get the list of abilities and their values |
396 | * |
397 | * @return bool[] |
398 | */ |
399 | private function getAbilities() { |
400 | return [ |
401 | 'shell' => !Shell::isDisabled(), |
402 | ]; |
403 | } |
404 | |
405 | /** |
406 | * Queries information about the software environment and constructs an appropriate version checker |
407 | * |
408 | * @return VersionChecker |
409 | */ |
410 | private function buildVersionChecker() { |
411 | // array to optionally specify more verbose error messages for |
412 | // missing abilities |
413 | $abilityErrors = [ |
414 | 'shell' => ( new ShellDisabledError() )->getMessage(), |
415 | ]; |
416 | |
417 | return new VersionChecker( |
418 | MW_VERSION, |
419 | PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION, |
420 | get_loaded_extensions(), |
421 | $this->getAbilities(), |
422 | $abilityErrors |
423 | ); |
424 | } |
425 | |
426 | /** |
427 | * Process a queue of extensions and return their extracted data |
428 | * |
429 | * @internal since 1.39. Extensions should use ExtensionProcessor instead. |
430 | * |
431 | * @param int[] $queue keys are filenames, values are ignored |
432 | * |
433 | * @return array extracted info |
434 | * @throws InvalidArgumentException |
435 | * @throws ExtensionDependencyError |
436 | */ |
437 | public function readFromQueue( array $queue ) { |
438 | $processor = new ExtensionProcessor(); |
439 | $versionChecker = $this->buildVersionChecker(); |
440 | $extDependencies = []; |
441 | $warnings = false; |
442 | foreach ( $queue as $path => $mtime ) { |
443 | $json = file_get_contents( $path ); |
444 | if ( $json === false ) { |
445 | throw new InvalidArgumentException( "Unable to read $path, does it exist?" ); |
446 | } |
447 | $info = json_decode( $json, /* $assoc = */ true ); |
448 | if ( !is_array( $info ) ) { |
449 | throw new InvalidArgumentException( "$path is not a valid JSON file." ); |
450 | } |
451 | |
452 | $version = $info['manifest_version']; |
453 | if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) { |
454 | throw new InvalidArgumentException( "$path: unsupported manifest_version: {$version}" ); |
455 | } |
456 | |
457 | // get all requirements/dependencies for this extension |
458 | $requires = $processor->getRequirements( $info, $this->checkDev ); |
459 | |
460 | // validate the information needed and add the requirements |
461 | if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) { |
462 | $extDependencies[$info['name']] = $requires; |
463 | } |
464 | |
465 | // Compatible, read and extract info |
466 | $processor->extractInfo( $path, $info, $version ); |
467 | } |
468 | $data = $processor->getExtractedInfo( $this->loadTestClassesAndNamespaces ); |
469 | $data['warnings'] = $warnings; |
470 | |
471 | // check for incompatible extensions |
472 | $incompatible = $versionChecker |
473 | ->setLoadedExtensionsAndSkins( $data['credits'] ) |
474 | ->checkArray( $extDependencies ); |
475 | |
476 | if ( $incompatible ) { |
477 | throw new ExtensionDependencyError( $incompatible ); |
478 | } |
479 | |
480 | return $data; |
481 | } |
482 | |
483 | protected function exportExtractedData( array $info ) { |
484 | if ( $info['globals'] ) { |
485 | // Create a copy of the keys to allow fast access via isset also for null values |
486 | // Since php8.1 always a read-only copy is created when the whole object is passed on function calls |
487 | // (like for array_key_exists). See T366547 - https://wiki.php.net/rfc/restrict_globals_usage |
488 | $knownGlobals = array_fill_keys( array_keys( $GLOBALS ), true ); |
489 | |
490 | foreach ( $info['globals'] as $key => $val ) { |
491 | // If a merge strategy is set, read it and remove it from the value |
492 | // so it doesn't accidentally end up getting set. |
493 | if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) { |
494 | $mergeStrategy = $val[self::MERGE_STRATEGY]; |
495 | unset( $val[self::MERGE_STRATEGY] ); |
496 | } else { |
497 | $mergeStrategy = 'array_merge'; |
498 | } |
499 | |
500 | if ( $mergeStrategy === 'provide_default' ) { |
501 | if ( !isset( $knownGlobals[$key] ) ) { |
502 | $GLOBALS[$key] = $val; |
503 | $knownGlobals[$key] = true; |
504 | } |
505 | continue; |
506 | } |
507 | |
508 | // Performance optimization: When the global doesn't exist (not even with null), just set it |
509 | if ( !isset( $knownGlobals[$key] ) ) { |
510 | $GLOBALS[$key] = $val; |
511 | $knownGlobals[$key] = true; |
512 | continue; |
513 | } elseif ( !is_array( $val ) || !is_array( $GLOBALS[$key] ) ) { |
514 | // When at least one of the global value and the default is not an array, the merge |
515 | // strategy is ignored and the global value will simply override the default. |
516 | continue; |
517 | } elseif ( !$GLOBALS[$key] ) { |
518 | // Performance optimization: When the target is an empty array, just set it |
519 | $GLOBALS[$key] = $val; |
520 | continue; |
521 | } |
522 | |
523 | switch ( $mergeStrategy ) { |
524 | case 'array_merge_recursive': |
525 | $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val ); |
526 | break; |
527 | case 'array_replace_recursive': |
528 | $GLOBALS[$key] = array_replace_recursive( $val, $GLOBALS[$key] ); |
529 | break; |
530 | case 'array_plus_2d': |
531 | $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val ); |
532 | break; |
533 | case 'array_plus': |
534 | $GLOBALS[$key] += $val; |
535 | break; |
536 | case 'array_merge': |
537 | $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] ); |
538 | break; |
539 | default: |
540 | throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" ); |
541 | } |
542 | } |
543 | } |
544 | |
545 | if ( isset( $info['autoloaderNS'] ) ) { |
546 | AutoLoader::registerNamespaces( $info['autoloaderNS'] ); |
547 | } |
548 | |
549 | if ( isset( $info['autoloaderClasses'] ) ) { |
550 | AutoLoader::registerClasses( $info['autoloaderClasses'] ); |
551 | } |
552 | |
553 | foreach ( $info['defines'] as $name => $val ) { |
554 | if ( !defined( $name ) ) { |
555 | define( $name, $val ); |
556 | } elseif ( constant( $name ) !== $val ) { |
557 | throw new UnexpectedValueException( |
558 | "$name cannot be re-defined with $val it has already been set with " . constant( $name ) |
559 | ); |
560 | } |
561 | } |
562 | |
563 | if ( isset( $info['autoloaderPaths'] ) ) { |
564 | AutoLoader::loadFiles( $info['autoloaderPaths'] ); |
565 | } |
566 | |
567 | $this->loaded += $info['credits']; |
568 | if ( $info['attributes'] ) { |
569 | if ( !$this->attributes ) { |
570 | $this->attributes = $info['attributes']; |
571 | } else { |
572 | $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] ); |
573 | } |
574 | } |
575 | |
576 | // XXX: SettingsBuilder should really be a parameter to this method. |
577 | $settings = $this->getSettingsBuilder(); |
578 | |
579 | foreach ( $info['callbacks'] as $name => $cb ) { |
580 | if ( !is_callable( $cb ) ) { |
581 | if ( is_array( $cb ) ) { |
582 | $cb = '[ ' . implode( ', ', $cb ) . ' ]'; |
583 | } |
584 | throw new UnexpectedValueException( "callback '$cb' is not callable" ); |
585 | } |
586 | $cb( $info['credits'][$name], $settings ); |
587 | } |
588 | } |
589 | |
590 | /** |
591 | * Whether a thing has been loaded |
592 | * |
593 | * @param string $name |
594 | * @param string $constraint The required version constraint for this dependency |
595 | * @return bool |
596 | */ |
597 | public function isLoaded( $name, $constraint = '*' ) { |
598 | $isLoaded = isset( $this->loaded[$name] ); |
599 | if ( $constraint === '*' || !$isLoaded ) { |
600 | return $isLoaded; |
601 | } |
602 | // if a specific constraint is requested, but no version is set, throw an exception |
603 | if ( !isset( $this->loaded[$name]['version'] ) ) { |
604 | $msg = "{$name} does not expose its version, but an extension or a skin" |
605 | . " requires: {$constraint}."; |
606 | throw new LogicException( $msg ); |
607 | } |
608 | |
609 | return Semver::satisfies( $this->loaded[$name]['version'], $constraint ); |
610 | } |
611 | |
612 | /** |
613 | * @param string $name |
614 | * |
615 | * @return array |
616 | */ |
617 | public function getAttribute( $name ) { |
618 | if ( isset( $this->testAttributes[$name] ) ) { |
619 | return $this->testAttributes[$name]; |
620 | } |
621 | |
622 | if ( in_array( $name, self::LAZY_LOADED_ATTRIBUTES, true ) ) { |
623 | return $this->getLazyLoadedAttribute( $name ); |
624 | } |
625 | |
626 | return $this->attributes[$name] ?? []; |
627 | } |
628 | |
629 | /** |
630 | * Register any domain event subscribers defined by extensions. |
631 | * |
632 | * @internal |
633 | */ |
634 | public function registerListeners( DomainEventSource $eventSource ): void { |
635 | foreach ( $this->getAttribute( 'DomainEventSubscribers' ) as $subscriber ) { |
636 | $eventSource->registerSubscriber( $subscriber ); |
637 | } |
638 | } |
639 | |
640 | /** |
641 | * Get an attribute value that isn't cached by reading each |
642 | * extension.json file again |
643 | * |
644 | * @param string $name |
645 | * |
646 | * @return array |
647 | */ |
648 | protected function getLazyLoadedAttribute( $name ) { |
649 | if ( isset( $this->testAttributes[$name] ) ) { |
650 | return $this->testAttributes[$name]; |
651 | } |
652 | if ( isset( $this->lazyAttributes[$name] ) ) { |
653 | return $this->lazyAttributes[$name]; |
654 | } |
655 | |
656 | // See if it's in the cache |
657 | $cache = $this->getCache(); |
658 | $key = $this->makeCacheKey( $cache, 'lazy-attrib', $name ); |
659 | $data = $cache->get( $key ); |
660 | if ( $data !== false ) { |
661 | $this->lazyAttributes[$name] = $data; |
662 | |
663 | return $data; |
664 | } |
665 | |
666 | $paths = []; |
667 | foreach ( $this->loaded as $info ) { |
668 | // mtime (array value) doesn't matter here since |
669 | // we're skipping cache, so use a dummy time |
670 | $paths[$info['path']] = 1; |
671 | } |
672 | |
673 | $result = $this->readFromQueue( $paths ); |
674 | $data = $result['attributes'][$name] ?? []; |
675 | $this->saveToCache( $cache, $result ); |
676 | $this->lazyAttributes[$name] = $data; |
677 | |
678 | return $data; |
679 | } |
680 | |
681 | /** |
682 | * Force override the value of an attribute during tests |
683 | * |
684 | * @param string $name Name of attribute to override |
685 | * @param array $value Value to set |
686 | * |
687 | * @return ScopedCallback to reset |
688 | * @since 1.33 |
689 | */ |
690 | public function setAttributeForTest( $name, array $value ) { |
691 | // @codeCoverageIgnoreStart |
692 | if ( !defined( 'MW_PHPUNIT_TEST' ) ) { |
693 | throw new LogicException( __METHOD__ . ' can only be used in tests' ); |
694 | } |
695 | // @codeCoverageIgnoreEnd |
696 | if ( isset( $this->testAttributes[$name] ) ) { |
697 | throw new InvalidArgumentException( "The attribute '$name' has already been overridden" ); |
698 | } |
699 | $this->testAttributes[$name] = $value; |
700 | |
701 | return new ScopedCallback( function () use ( $name ) { |
702 | unset( $this->testAttributes[$name] ); |
703 | } ); |
704 | } |
705 | |
706 | /** |
707 | * Get credits information about all installed extensions and skins. |
708 | * |
709 | * @return array[] Keyed by component name. |
710 | */ |
711 | public function getAllThings() { |
712 | return $this->loaded; |
713 | } |
714 | |
715 | /** |
716 | * Fully expand autoloader paths |
717 | * |
718 | * @param string $dir |
719 | * @param string[] $files |
720 | * |
721 | * @return array |
722 | */ |
723 | protected static function processAutoLoader( $dir, array $files ) { |
724 | // Make paths absolute, relative to the JSON file |
725 | foreach ( $files as &$file ) { |
726 | $file = "$dir/$file"; |
727 | } |
728 | |
729 | return $files; |
730 | } |
731 | |
732 | /** |
733 | * @internal for use by Setup. Hopefully in the future, we find a better way. |
734 | * |
735 | * @param SettingsBuilder $settingsBuilder |
736 | */ |
737 | public function setSettingsBuilder( SettingsBuilder $settingsBuilder ) { |
738 | $this->settingsBuilder = $settingsBuilder; |
739 | } |
740 | |
741 | private function getSettingsBuilder(): SettingsBuilder { |
742 | if ( $this->settingsBuilder === null ) { |
743 | $this->settingsBuilder = SettingsBuilder::getInstance(); |
744 | } |
745 | |
746 | return $this->settingsBuilder; |
747 | } |
748 | } |
749 | |
750 | /** @deprecated class alias since 1.43 */ |
751 | class_alias( ExtensionRegistry::class, 'ExtensionRegistry' ); |