MediaWiki master
ExtensionRegistry.php
Go to the documentation of this file.
1<?php
2
4
5use AutoLoader;
6use Composer\Semver\Semver;
7use InvalidArgumentException;
8use LogicException;
16use RuntimeException;
17use UnexpectedValueException;
19use Wikimedia\ScopedCallback;
20
36
40 public const MEDIAWIKI_CORE = 'MediaWiki';
41
46 public const MANIFEST_VERSION = 2;
47
52 public const MANIFEST_VERSION_MW_VERSION = '>= 1.29.0';
53
57 public const OLDEST_MANIFEST_VERSION = 1;
58
62 private const CACHE_VERSION = 8;
63
64 private const CACHE_EXPIRY = 60 * 60 * 24;
65
71 public const MERGE_STRATEGY = '_merge_strategy';
72
76 private const LAZY_LOADED_ATTRIBUTES = [
77 'TrackingCategories',
78 'QUnitTestModules',
79 'SkinLessImportPaths',
80 ];
81
93 private $loaded = [];
94
100 protected $queued = [];
101
107 private $finished = false;
108
115 protected $attributes = [];
116
122 protected $testAttributes = [];
123
129 protected $lazyAttributes = [];
130
136 private $varyHash;
137
143 protected $checkDev = false;
144
151
155 private static $instance;
156
160 private $cache = null;
161
165 private ?SettingsBuilder $settingsBuilder = null;
166
167 private static bool $accessDisabledForUnitTests = false;
168
173 public static function getInstance() {
174 if ( self::$accessDisabledForUnitTests ) {
175 throw new RuntimeException( 'Access is disabled in unit tests' );
176 }
177 if ( self::$instance === null ) {
178 self::$instance = new self();
179 }
180
181 return self::$instance;
182 }
183
187 public static function disableForTest(): void {
188 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
189 throw new RuntimeException( 'Can only be called in tests' );
190 }
191 self::$accessDisabledForUnitTests = true;
192 }
193
197 public static function enableForTest(): void {
198 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
199 throw new RuntimeException( 'Can only be called in tests' );
200 }
201 self::$accessDisabledForUnitTests = false;
202 }
203
212 public function setCache( BagOStuff $cache ): void {
213 $this->cache = $cache;
214 }
215
221 public function setCheckDevRequires( $check ) {
222 $this->checkDev = $check;
223 $this->invalidateProcessCache();
224 }
225
234 public function setLoadTestClassesAndNamespaces( $load ) {
235 $this->loadTestClassesAndNamespaces = $load;
236 }
237
241 public function queue( $path ) {
243
244 $mtime = $wgExtensionInfoMTime;
245 if ( $mtime === false ) {
246 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
247 $mtime = @filemtime( $path );
248 // @codeCoverageIgnoreStart
249 if ( $mtime === false ) {
250 $err = error_get_last();
251 throw new MissingExtensionException( $path, $err['message'] );
252 // @codeCoverageIgnoreEnd
253 }
254 }
255 $this->queued[$path] = $mtime;
256 $this->invalidateProcessCache();
257 }
258
259 private function getCache(): BagOStuff {
260 if ( !$this->cache ) {
261 // NOTE: Copy of ObjectCacheFactory::getDefaultKeyspace
262 //
263 // Can't call MediaWikiServices here, as we must not cause services
264 // to be instantiated before extensions have loaded.
265 global $wgCachePrefix;
266 $keyspace = ( is_string( $wgCachePrefix ) && $wgCachePrefix !== '' )
268 : WikiMap::getCurrentWikiDbDomain()->getId();
269
270 return ObjectCacheFactory::makeLocalServerCache( $keyspace );
271 }
272
273 return $this->cache;
274 }
275
276 private function makeCacheKey( BagOStuff $cache, $component, ...$extra ) {
277 // Allow reusing cached ExtensionRegistry metadata between wikis (T274648)
278 return $cache->makeGlobalKey(
279 "registration-$component",
280 $this->getVaryHash(),
281 ...$extra
282 );
283 }
284
290 private function getVaryHash() {
291 if ( $this->varyHash === null ) {
292 // We vary the cache on the current queue (what will be or already was loaded)
293 // plus various versions of stuff for VersionChecker
294 $vary = [
295 'registration' => self::CACHE_VERSION,
296 'mediawiki' => MW_VERSION,
297 'abilities' => $this->getAbilities(),
298 'checkDev' => $this->checkDev,
299 'queue' => $this->queued,
300 ];
301 $this->varyHash = md5( json_encode( $vary ) );
302 }
303
304 return $this->varyHash;
305 }
306
310 private function invalidateProcessCache() {
311 $this->varyHash = null;
312 $this->lazyAttributes = [];
313 }
314
315 public function loadFromQueue() {
316 if ( !$this->queued ) {
317 return;
318 }
319
320 if ( $this->finished ) {
321 throw new LogicException(
322 "The following paths tried to load late: "
323 . implode( ', ', array_keys( $this->queued ) )
324 );
325 }
326
327 $cache = $this->getCache();
328 // See if this queue is in APC
329 $key = $this->makeCacheKey( $cache, 'main' );
330 $data = $cache->get( $key );
331 if ( !$data ) {
332 $data = $this->readFromQueue( $this->queued );
333 $this->saveToCache( $cache, $data );
334 }
335 $this->exportExtractedData( $data );
336 }
337
344 protected function saveToCache( BagOStuff $cache, array $data ) {
346 if ( $data['warnings'] && $wgDevelopmentWarnings ) {
347 // If warnings were shown, don't cache it
348 return;
349 }
350 $lazy = [];
351 // Cache lazy-loaded attributes separately
352 foreach ( self::LAZY_LOADED_ATTRIBUTES as $attrib ) {
353 if ( isset( $data['attributes'][$attrib] ) ) {
354 $lazy[$attrib] = $data['attributes'][$attrib];
355 unset( $data['attributes'][$attrib] );
356 }
357 }
358 $mainKey = $this->makeCacheKey( $cache, 'main' );
359 $cache->set( $mainKey, $data, self::CACHE_EXPIRY );
360 foreach ( $lazy as $attrib => $value ) {
361 $cache->set(
362 $this->makeCacheKey( $cache, 'lazy-attrib', $attrib ),
363 $value,
364 self::CACHE_EXPIRY
365 );
366 }
367 }
368
375 public function getQueue() {
376 return $this->queued;
377 }
378
383 public function clearQueue() {
384 $this->queued = [];
385 $this->invalidateProcessCache();
386 }
387
393 public function finish() {
394 $this->finished = true;
395 }
396
402 private function getAbilities() {
403 return [
404 'shell' => !Shell::isDisabled(),
405 ];
406 }
407
413 private function buildVersionChecker() {
414 // array to optionally specify more verbose error messages for
415 // missing abilities
416 $abilityErrors = [
417 'shell' => ( new ShellDisabledError() )->getMessage(),
418 ];
419
420 return new VersionChecker(
422 PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION,
423 get_loaded_extensions(),
424 $this->getAbilities(),
425 $abilityErrors
426 );
427 }
428
440 public function readFromQueue( array $queue ) {
441 $processor = new ExtensionProcessor();
442 $versionChecker = $this->buildVersionChecker();
443 $extDependencies = [];
444 $warnings = false;
445 foreach ( $queue as $path => $mtime ) {
446 $json = file_get_contents( $path );
447 if ( $json === false ) {
448 throw new InvalidArgumentException( "Unable to read $path, does it exist?" );
449 }
450 $info = json_decode( $json, /* $assoc = */ true );
451 if ( !is_array( $info ) ) {
452 throw new InvalidArgumentException( "$path is not a valid JSON file." );
453 }
454
455 $version = $info['manifest_version'];
456 if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) {
457 throw new InvalidArgumentException( "$path: unsupported manifest_version: {$version}" );
458 }
459
460 // get all requirements/dependencies for this extension
461 $requires = $processor->getRequirements( $info, $this->checkDev );
462
463 // validate the information needed and add the requirements
464 if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) {
465 $extDependencies[$info['name']] = $requires;
466 }
467
468 // Compatible, read and extract info
469 $processor->extractInfo( $path, $info, $version );
470 }
471 $data = $processor->getExtractedInfo( $this->loadTestClassesAndNamespaces );
472 $data['warnings'] = $warnings;
473
474 // check for incompatible extensions
475 $incompatible = $versionChecker
476 ->setLoadedExtensionsAndSkins( $data['credits'] )
477 ->checkArray( $extDependencies );
478
479 if ( $incompatible ) {
480 throw new ExtensionDependencyError( $incompatible );
481 }
482
483 return $data;
484 }
485
486 protected function exportExtractedData( array $info ) {
487 if ( $info['globals'] ) {
488 // Create a copy of the keys to allow fast access via isset also for null values
489 // Since php8.1 always a read-only copy is created when the whole object is passed on function calls
490 // (like for array_key_exists). See T366547 - https://wiki.php.net/rfc/restrict_globals_usage
491 $knownGlobals = array_fill_keys( array_keys( $GLOBALS ), true );
492
493 foreach ( $info['globals'] as $key => $val ) {
494 // If a merge strategy is set, read it and remove it from the value
495 // so it doesn't accidentally end up getting set.
496 if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) {
497 $mergeStrategy = $val[self::MERGE_STRATEGY];
498 unset( $val[self::MERGE_STRATEGY] );
499 } else {
500 $mergeStrategy = 'array_merge';
501 }
502
503 if ( $mergeStrategy === 'provide_default' ) {
504 if ( !isset( $knownGlobals[$key] ) ) {
505 $GLOBALS[$key] = $val;
506 $knownGlobals[$key] = true;
507 }
508 continue;
509 }
510
511 // Performance optimization: When the global doesn't exist (not even with null), just set it
512 if ( !isset( $knownGlobals[$key] ) ) {
513 $GLOBALS[$key] = $val;
514 $knownGlobals[$key] = true;
515 continue;
516 } elseif ( !is_array( $val ) || !is_array( $GLOBALS[$key] ) ) {
517 // When at least one of the global value and the default is not an array, the merge
518 // strategy is ignored and the global value will simply override the default.
519 continue;
520 } elseif ( !$GLOBALS[$key] ) {
521 // Performance optimization: When the target is an empty array, just set it
522 $GLOBALS[$key] = $val;
523 continue;
524 }
525
526 switch ( $mergeStrategy ) {
527 case 'array_merge_recursive':
528 $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
529 break;
530 case 'array_replace_recursive':
531 $GLOBALS[$key] = array_replace_recursive( $val, $GLOBALS[$key] );
532 break;
533 case 'array_plus_2d':
534 $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val );
535 break;
536 case 'array_plus':
537 $GLOBALS[$key] += $val;
538 break;
539 case 'array_merge':
540 $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] );
541 break;
542 default:
543 throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" );
544 }
545 }
546 }
547
548 if ( isset( $info['autoloaderNS'] ) ) {
549 AutoLoader::registerNamespaces( $info['autoloaderNS'] );
550 }
551
552 if ( isset( $info['autoloaderClasses'] ) ) {
553 AutoLoader::registerClasses( $info['autoloaderClasses'] );
554 }
555
556 foreach ( $info['defines'] as $name => $val ) {
557 if ( !defined( $name ) ) {
558 define( $name, $val );
559 } elseif ( constant( $name ) !== $val ) {
560 throw new UnexpectedValueException(
561 "$name cannot be re-defined with $val it has already been set with " . constant( $name )
562 );
563 }
564 }
565
566 if ( isset( $info['autoloaderPaths'] ) ) {
567 AutoLoader::loadFiles( $info['autoloaderPaths'] );
568 }
569
570 $this->loaded += $info['credits'];
571 if ( $info['attributes'] ) {
572 if ( !$this->attributes ) {
573 $this->attributes = $info['attributes'];
574 } else {
575 $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
576 }
577 }
578
579 // XXX: SettingsBuilder should really be a parameter to this method.
580 $settings = $this->getSettingsBuilder();
581
582 foreach ( $info['callbacks'] as $name => $cb ) {
583 if ( !is_callable( $cb ) ) {
584 if ( is_array( $cb ) ) {
585 $cb = '[ ' . implode( ', ', $cb ) . ' ]';
586 }
587 throw new UnexpectedValueException( "callback '$cb' is not callable" );
588 }
589 $cb( $info['credits'][$name], $settings );
590 }
591 }
592
600 public function isLoaded( $name, $constraint = '*' ) {
601 $isLoaded = isset( $this->loaded[$name] );
602 if ( $constraint === '*' || !$isLoaded ) {
603 return $isLoaded;
604 }
605 // if a specific constraint is requested, but no version is set, throw an exception
606 if ( !isset( $this->loaded[$name]['version'] ) ) {
607 $msg = "{$name} does not expose its version, but an extension or a skin"
608 . " requires: {$constraint}.";
609 throw new LogicException( $msg );
610 }
611
612 return Semver::satisfies( $this->loaded[$name]['version'], $constraint );
613 }
614
620 public function getAttribute( $name ) {
621 if ( isset( $this->testAttributes[$name] ) ) {
622 return $this->testAttributes[$name];
623 }
624
625 if ( in_array( $name, self::LAZY_LOADED_ATTRIBUTES, true ) ) {
626 return $this->getLazyLoadedAttribute( $name );
627 }
628
629 return $this->attributes[$name] ?? [];
630 }
631
637 public function registerListeners( DomainEventSource $eventSource ): void {
638 foreach ( $this->getAttribute( 'DomainEventSubscribers' ) as $subscriber ) {
639 $eventSource->registerSubscriber( $subscriber );
640 }
641 }
642
651 protected function getLazyLoadedAttribute( $name ) {
652 if ( isset( $this->testAttributes[$name] ) ) {
653 return $this->testAttributes[$name];
654 }
655 if ( isset( $this->lazyAttributes[$name] ) ) {
656 return $this->lazyAttributes[$name];
657 }
658
659 // See if it's in the cache
660 $cache = $this->getCache();
661 $key = $this->makeCacheKey( $cache, 'lazy-attrib', $name );
662 $data = $cache->get( $key );
663 if ( $data !== false ) {
664 $this->lazyAttributes[$name] = $data;
665
666 return $data;
667 }
668
669 $paths = [];
670 foreach ( $this->loaded as $info ) {
671 // mtime (array value) doesn't matter here since
672 // we're skipping cache, so use a dummy time
673 $paths[$info['path']] = 1;
674 }
675
676 $result = $this->readFromQueue( $paths );
677 $data = $result['attributes'][$name] ?? [];
678 $this->saveToCache( $cache, $result );
679 $this->lazyAttributes[$name] = $data;
680
681 return $data;
682 }
683
693 public function setAttributeForTest( $name, array $value ) {
694 // @codeCoverageIgnoreStart
695 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
696 throw new LogicException( __METHOD__ . ' can only be used in tests' );
697 }
698 // @codeCoverageIgnoreEnd
699 if ( isset( $this->testAttributes[$name] ) ) {
700 throw new InvalidArgumentException( "The attribute '$name' has already been overridden" );
701 }
702 $this->testAttributes[$name] = $value;
703
704 return new ScopedCallback( function () use ( $name ) {
705 unset( $this->testAttributes[$name] );
706 } );
707 }
708
714 public function getAllThings() {
715 return $this->loaded;
716 }
717
726 protected static function processAutoLoader( $dir, array $files ) {
727 // Make paths absolute, relative to the JSON file
728 foreach ( $files as &$file ) {
729 $file = "$dir/$file";
730 }
731
732 return $files;
733 }
734
740 public function setSettingsBuilder( SettingsBuilder $settingsBuilder ) {
741 $this->settingsBuilder = $settingsBuilder;
742 }
743
744 private function getSettingsBuilder(): SettingsBuilder {
745 if ( $this->settingsBuilder === null ) {
746 $this->settingsBuilder = SettingsBuilder::getInstance();
747 }
748
749 return $this->settingsBuilder;
750 }
751}
752
754class_alias( ExtensionRegistry::class, 'ExtensionRegistry' );
const MW_VERSION
The running version of MediaWiki.
Definition Defines.php:37
wfArrayPlus2d(array $baseArray, array $newValues)
Merges two (possibly) 2 dimensional arrays into the target array ($baseArray).
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:81
Locations of core classes Extension classes are specified with $wgAutoloadClasses.
Load extension manifests and then aggregate their contents.
Load JSON files, and uses a Processor to extract information.
clearQueue()
Clear the current load queue.
array $testAttributes
Attributes for testing.
finish()
After this is called, no more extensions can be loaded.
isLoaded( $name, $constraint=' *')
Whether a thing has been loaded.
static processAutoLoader( $dir, array $files)
Fully expand autoloader paths.
getAllThings()
Get credits information about all installed extensions and skins.
registerListeners(DomainEventSource $eventSource)
Register any domain event subscribers defined by extensions.
setAttributeForTest( $name, array $value)
Force override the value of an attribute during tests.
array $attributes
Items in the JSON file that aren't being set as globals.
const MERGE_STRATEGY
Special key that defines the merge strategy.
int[] $queued
List of paths that should be loaded.
setCache(BagOStuff $cache)
Set the cache to use for extension info.
array $lazyAttributes
Lazy-loaded attributes.
setLoadTestClassesAndNamespaces( $load)
Controls if classes and namespaces defined under the keys TestAutoloadClasses and TestAutoloadNamespa...
saveToCache(BagOStuff $cache, array $data)
Save data in the cache.
const MEDIAWIKI_CORE
"requires" key that applies to MediaWiki core
bool $checkDev
Whether to check dev-requires.
const MANIFEST_VERSION
Version of the highest supported manifest version Note: Update MANIFEST_VERSION_MW_VERSION when chang...
readFromQueue(array $queue)
Process a queue of extensions and return their extracted data.
getLazyLoadedAttribute( $name)
Get an attribute value that isn't cached by reading each extension.json file again.
bool $loadTestClassesAndNamespaces
Whether test classes and namespaces should be added to the auto loader.
const OLDEST_MANIFEST_VERSION
Version of the oldest supported manifest version.
const MANIFEST_VERSION_MW_VERSION
MediaWiki version constraint representing what the current highest MANIFEST_VERSION is supported in.
setSettingsBuilder(SettingsBuilder $settingsBuilder)
Thrown when ExtensionRegistry cannot open the extension.json or skin.json file.
Builder class for constructing a Config object from a set of sources during bootstrap.
Executes shell commands.
Definition Shell.php:46
Tools for dealing with other locally-hosted wikis.
Definition WikiMap.php:31
Factory for cache objects as configured in the ObjectCaches setting.
Abstract class for any ephemeral data store.
Definition BagOStuff.php:89
set( $key, $value, $exptime=0, $flags=0)
Set an item.
$wgCachePrefix
Config variable stub for the CachePrefix setting, for use by phpdoc and IDEs.
$wgExtensionInfoMTime
Config variable stub for the ExtensionInfoMTime setting, for use by phpdoc and IDEs.
$wgDevelopmentWarnings
Config variable stub for the DevelopmentWarnings setting, for use by phpdoc and IDEs.
Service object for registering listeners for domain events.
registerSubscriber( $subscriber)
Register the given subscriber to this event source.
Objects implementing DomainEventSubscriber represent a collection of related event listeners.