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
162 private ?SettingsBuilder $settingsBuilder = null;
163
164 private static bool $accessDisabledForUnitTests = false;
165
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
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
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
209 public function setCache( BagOStuff $cache ): void {
210 $this->cache = $cache;
211 }
212
218 public function setCheckDevRequires( $check ) {
219 $this->checkDev = $check;
220 $this->invalidateProcessCache();
221 }
222
231 public function setLoadTestClassesAndNamespaces( $load ) {
232 $this->loadTestClassesAndNamespaces = $load;
233 }
234
238 public function queue( $path ) {
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 !== '' )
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
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
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
341 protected function saveToCache( BagOStuff $cache, array $data ) {
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
372 public function getQueue() {
373 return $this->queued;
374 }
375
380 public function clearQueue() {
381 $this->queued = [];
382 $this->invalidateProcessCache();
383 }
384
390 public function finish() {
391 $this->finished = true;
392 }
393
399 private function getAbilities() {
400 return [
401 'shell' => !Shell::isDisabled(),
402 ];
403 }
404
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(
419 PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION,
420 get_loaded_extensions(),
421 $this->getAbilities(),
422 $abilityErrors
423 );
424 }
425
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
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
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
634 public function registerListeners( DomainEventSource $eventSource ): void {
635 $subscribers = $this->getAttribute( 'DomainEventIngresses' );
636
637 foreach ( $subscribers as $subscriber ) {
638 $eventSource->registerSubscriber( $subscriber );
639 }
640 }
641
650 protected function getLazyLoadedAttribute( $name ) {
651 if ( isset( $this->testAttributes[$name] ) ) {
652 return $this->testAttributes[$name];
653 }
654 if ( isset( $this->lazyAttributes[$name] ) ) {
655 return $this->lazyAttributes[$name];
656 }
657
658 // See if it's in the cache
659 $cache = $this->getCache();
660 $key = $this->makeCacheKey( $cache, 'lazy-attrib', $name );
661 $data = $cache->get( $key );
662 if ( $data !== false ) {
663 $this->lazyAttributes[$name] = $data;
664
665 return $data;
666 }
667
668 $paths = [];
669 foreach ( $this->loaded as $info ) {
670 // mtime (array value) doesn't matter here since
671 // we're skipping cache, so use a dummy time
672 $paths[$info['path']] = 1;
673 }
674
675 $result = $this->readFromQueue( $paths );
676 $data = $result['attributes'][$name] ?? [];
677 $this->saveToCache( $cache, $result );
678 $this->lazyAttributes[$name] = $data;
679
680 return $data;
681 }
682
692 public function setAttributeForTest( $name, array $value ) {
693 // @codeCoverageIgnoreStart
694 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
695 throw new LogicException( __METHOD__ . ' can only be used in tests' );
696 }
697 // @codeCoverageIgnoreEnd
698 if ( isset( $this->testAttributes[$name] ) ) {
699 throw new InvalidArgumentException( "The attribute '$name' has already been overridden" );
700 }
701 $this->testAttributes[$name] = $value;
702
703 return new ScopedCallback( function () use ( $name ) {
704 unset( $this->testAttributes[$name] );
705 } );
706 }
707
713 public function getAllThings() {
714 return $this->loaded;
715 }
716
725 protected static function processAutoLoader( $dir, array $files ) {
726 // Make paths absolute, relative to the JSON file
727 foreach ( $files as &$file ) {
728 $file = "$dir/$file";
729 }
730
731 return $files;
732 }
733
739 public function setSettingsBuilder( SettingsBuilder $settingsBuilder ) {
740 $this->settingsBuilder = $settingsBuilder;
741 }
742
743 private function getSettingsBuilder(): SettingsBuilder {
744 if ( $this->settingsBuilder === null ) {
745 $this->settingsBuilder = SettingsBuilder::getInstance();
746 }
747
748 return $this->settingsBuilder;
749 }
750}
751
753class_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:88
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.