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;
14use RuntimeException;
15use UnexpectedValueException;
17use Wikimedia\ScopedCallback;
18
34
38 public const MEDIAWIKI_CORE = 'MediaWiki';
39
44 public const MANIFEST_VERSION = 2;
45
50 public const MANIFEST_VERSION_MW_VERSION = '>= 1.29.0';
51
55 public const OLDEST_MANIFEST_VERSION = 1;
56
60 private const CACHE_VERSION = 8;
61
62 private const CACHE_EXPIRY = 60 * 60 * 24;
63
69 public const MERGE_STRATEGY = '_merge_strategy';
70
74 private const LAZY_LOADED_ATTRIBUTES = [
75 'TrackingCategories',
76 'QUnitTestModules',
77 'SkinLessImportPaths',
78 ];
79
91 private $loaded = [];
92
98 protected $queued = [];
99
105 private $finished = false;
106
113 protected $attributes = [];
114
120 protected $testAttributes = [];
121
127 protected $lazyAttributes = [];
128
134 private $varyHash;
135
141 protected $checkDev = false;
142
149
153 private static $instance;
154
158 private $cache = null;
159
163 private ?SettingsBuilder $settingsBuilder = null;
164
165 private static bool $accessDisabledForUnitTests = false;
166
171 public static function getInstance() {
172 if ( self::$accessDisabledForUnitTests ) {
173 throw new RuntimeException( 'Access is disabled in unit tests' );
174 }
175 if ( self::$instance === null ) {
176 self::$instance = new self();
177 }
178
179 return self::$instance;
180 }
181
185 public static function disableForTest(): void {
186 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
187 throw new RuntimeException( 'Can only be called in tests' );
188 }
189 self::$accessDisabledForUnitTests = true;
190 }
191
195 public static function enableForTest(): void {
196 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
197 throw new RuntimeException( 'Can only be called in tests' );
198 }
199 self::$accessDisabledForUnitTests = false;
200 }
201
210 public function setCache( BagOStuff $cache ): void {
211 $this->cache = $cache;
212 }
213
219 public function setCheckDevRequires( $check ) {
220 $this->checkDev = $check;
221 $this->invalidateProcessCache();
222 }
223
232 public function setLoadTestClassesAndNamespaces( $load ) {
233 $this->loadTestClassesAndNamespaces = $load;
234 }
235
239 public function queue( $path ) {
241
242 $mtime = $wgExtensionInfoMTime;
243 if ( $mtime === false ) {
244 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
245 $mtime = @filemtime( $path );
246 // @codeCoverageIgnoreStart
247 if ( $mtime === false ) {
248 $err = error_get_last();
249 throw new MissingExtensionException( $path, $err['message'] );
250 // @codeCoverageIgnoreEnd
251 }
252 }
253 $this->queued[$path] = $mtime;
254 $this->invalidateProcessCache();
255 }
256
257 private function getCache(): BagOStuff {
258 if ( !$this->cache ) {
259 // NOTE: Copy of ObjectCacheFactory::getDefaultKeyspace
260 //
261 // Can't call MediaWikiServices here, as we must not cause services
262 // to be instantiated before extensions have loaded.
263 global $wgCachePrefix;
264 $keyspace = ( is_string( $wgCachePrefix ) && $wgCachePrefix !== '' )
266 : WikiMap::getCurrentWikiDbDomain()->getId();
267
268 return ObjectCacheFactory::makeLocalServerCache( $keyspace );
269 }
270
271 return $this->cache;
272 }
273
274 private function makeCacheKey( BagOStuff $cache, $component, ...$extra ) {
275 // Allow reusing cached ExtensionRegistry metadata between wikis (T274648)
276 return $cache->makeGlobalKey(
277 "registration-$component",
278 $this->getVaryHash(),
279 ...$extra
280 );
281 }
282
288 private function getVaryHash() {
289 if ( $this->varyHash === null ) {
290 // We vary the cache on the current queue (what will be or already was loaded)
291 // plus various versions of stuff for VersionChecker
292 $vary = [
293 'registration' => self::CACHE_VERSION,
294 'mediawiki' => MW_VERSION,
295 'abilities' => $this->getAbilities(),
296 'checkDev' => $this->checkDev,
297 'queue' => $this->queued,
298 ];
299 $this->varyHash = md5( json_encode( $vary ) );
300 }
301
302 return $this->varyHash;
303 }
304
308 private function invalidateProcessCache() {
309 $this->varyHash = null;
310 $this->lazyAttributes = [];
311 }
312
313 public function loadFromQueue() {
314 if ( !$this->queued ) {
315 return;
316 }
317
318 if ( $this->finished ) {
319 throw new LogicException(
320 "The following paths tried to load late: "
321 . implode( ', ', array_keys( $this->queued ) )
322 );
323 }
324
325 $cache = $this->getCache();
326 // See if this queue is in APC
327 $key = $this->makeCacheKey( $cache, 'main' );
328 $data = $cache->get( $key );
329 if ( !$data ) {
330 $data = $this->readFromQueue( $this->queued );
331 $this->saveToCache( $cache, $data );
332 }
333 $this->exportExtractedData( $data );
334 }
335
342 protected function saveToCache( BagOStuff $cache, array $data ) {
344 if ( $data['warnings'] && $wgDevelopmentWarnings ) {
345 // If warnings were shown, don't cache it
346 return;
347 }
348 $lazy = [];
349 // Cache lazy-loaded attributes separately
350 foreach ( self::LAZY_LOADED_ATTRIBUTES as $attrib ) {
351 if ( isset( $data['attributes'][$attrib] ) ) {
352 $lazy[$attrib] = $data['attributes'][$attrib];
353 unset( $data['attributes'][$attrib] );
354 }
355 }
356 $mainKey = $this->makeCacheKey( $cache, 'main' );
357 $cache->set( $mainKey, $data, self::CACHE_EXPIRY );
358 foreach ( $lazy as $attrib => $value ) {
359 $cache->set(
360 $this->makeCacheKey( $cache, 'lazy-attrib', $attrib ),
361 $value,
362 self::CACHE_EXPIRY
363 );
364 }
365 }
366
373 public function getQueue() {
374 return $this->queued;
375 }
376
381 public function clearQueue() {
382 $this->queued = [];
383 $this->invalidateProcessCache();
384 }
385
391 public function finish() {
392 $this->finished = true;
393 }
394
400 private function getAbilities() {
401 return [
402 'shell' => !Shell::isDisabled(),
403 ];
404 }
405
411 private function buildVersionChecker() {
412 // array to optionally specify more verbose error messages for
413 // missing abilities
414 $abilityErrors = [
415 'shell' => ( new ShellDisabledError() )->getMessage(),
416 ];
417
418 return new VersionChecker(
420 PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION,
421 get_loaded_extensions(),
422 $this->getAbilities(),
423 $abilityErrors
424 );
425 }
426
438 public function readFromQueue( array $queue ) {
439 $processor = new ExtensionProcessor();
440 $versionChecker = $this->buildVersionChecker();
441 $extDependencies = [];
442 $warnings = false;
443 foreach ( $queue as $path => $mtime ) {
444 $json = file_get_contents( $path );
445 if ( $json === false ) {
446 throw new InvalidArgumentException( "Unable to read $path, does it exist?" );
447 }
448 $info = json_decode( $json, /* $assoc = */ true );
449 if ( !is_array( $info ) ) {
450 throw new InvalidArgumentException( "$path is not a valid JSON file." );
451 }
452
453 $version = $info['manifest_version'];
454 if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) {
455 throw new InvalidArgumentException( "$path: unsupported manifest_version: {$version}" );
456 }
457
458 // get all requirements/dependencies for this extension
459 $requires = $processor->getRequirements( $info, $this->checkDev );
460
461 // validate the information needed and add the requirements
462 if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) {
463 $extDependencies[$info['name']] = $requires;
464 }
465
466 // Compatible, read and extract info
467 $processor->extractInfo( $path, $info, $version );
468 }
469 $data = $processor->getExtractedInfo( $this->loadTestClassesAndNamespaces );
470 $data['warnings'] = $warnings;
471
472 // check for incompatible extensions
473 $incompatible = $versionChecker
474 ->setLoadedExtensionsAndSkins( $data['credits'] )
475 ->checkArray( $extDependencies );
476
477 if ( $incompatible ) {
478 throw new ExtensionDependencyError( $incompatible );
479 }
480
481 return $data;
482 }
483
484 protected function exportExtractedData( array $info ) {
485 if ( $info['globals'] ) {
486 // Create a copy of the keys to allow fast access via isset also for null values
487 // Since php8.1 always a read-only copy is created when the whole object is passed on function calls
488 // (like for array_key_exists). See T366547 - https://wiki.php.net/rfc/restrict_globals_usage
489 $knownGlobals = array_fill_keys( array_keys( $GLOBALS ), true );
490
491 foreach ( $info['globals'] as $key => $val ) {
492 // If a merge strategy is set, read it and remove it from the value
493 // so it doesn't accidentally end up getting set.
494 if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) {
495 $mergeStrategy = $val[self::MERGE_STRATEGY];
496 unset( $val[self::MERGE_STRATEGY] );
497 } else {
498 $mergeStrategy = 'array_merge';
499 }
500
501 if ( $mergeStrategy === 'provide_default' ) {
502 if ( !isset( $knownGlobals[$key] ) ) {
503 $GLOBALS[$key] = $val;
504 $knownGlobals[$key] = true;
505 }
506 continue;
507 }
508
509 // Performance optimization: When the global doesn't exist (not even with null), just set it
510 if ( !isset( $knownGlobals[$key] ) ) {
511 $GLOBALS[$key] = $val;
512 $knownGlobals[$key] = true;
513 continue;
514 } elseif ( !is_array( $val ) || !is_array( $GLOBALS[$key] ) ) {
515 // When at least one of the global value and the default is not an array, the merge
516 // strategy is ignored and the global value will simply override the default.
517 continue;
518 } elseif ( !$GLOBALS[$key] ) {
519 // Performance optimization: When the target is an empty array, just set it
520 $GLOBALS[$key] = $val;
521 continue;
522 }
523
524 switch ( $mergeStrategy ) {
525 case 'array_merge_recursive':
526 $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
527 break;
528 case 'array_replace_recursive':
529 $GLOBALS[$key] = array_replace_recursive( $val, $GLOBALS[$key] );
530 break;
531 case 'array_plus_2d':
532 $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val );
533 break;
534 case 'array_plus':
535 $GLOBALS[$key] += $val;
536 break;
537 case 'array_merge':
538 $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] );
539 break;
540 default:
541 throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" );
542 }
543 }
544 }
545
546 if ( isset( $info['autoloaderNS'] ) ) {
547 AutoLoader::registerNamespaces( $info['autoloaderNS'] );
548 }
549
550 if ( isset( $info['autoloaderClasses'] ) ) {
551 AutoLoader::registerClasses( $info['autoloaderClasses'] );
552 }
553
554 foreach ( $info['defines'] as $name => $val ) {
555 if ( !defined( $name ) ) {
556 define( $name, $val );
557 } elseif ( constant( $name ) !== $val ) {
558 throw new UnexpectedValueException(
559 "$name cannot be re-defined with $val it has already been set with " . constant( $name )
560 );
561 }
562 }
563
564 if ( isset( $info['autoloaderPaths'] ) ) {
565 AutoLoader::loadFiles( $info['autoloaderPaths'] );
566 }
567
568 $this->loaded += $info['credits'];
569 if ( $info['attributes'] ) {
570 if ( !$this->attributes ) {
571 $this->attributes = $info['attributes'];
572 } else {
573 $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
574 }
575 }
576
577 // XXX: SettingsBuilder should really be a parameter to this method.
578 $settings = $this->getSettingsBuilder();
579
580 foreach ( $info['callbacks'] as $name => $cb ) {
581 if ( !is_callable( $cb ) ) {
582 if ( is_array( $cb ) ) {
583 $cb = '[ ' . implode( ', ', $cb ) . ' ]';
584 }
585 throw new UnexpectedValueException( "callback '$cb' is not callable" );
586 }
587 $cb( $info['credits'][$name], $settings );
588 }
589 }
590
601 public function isLoaded( $name, $constraint = '*' ) {
602 $isLoaded = isset( $this->loaded[$name] );
603 if ( $constraint === '*' || !$isLoaded ) {
604 return $isLoaded;
605 }
606 // if a specific constraint is requested, but no version is set, throw an exception
607 if ( !isset( $this->loaded[$name]['version'] ) ) {
608 $msg = "{$name} does not expose its version, but an extension or a skin"
609 . " requires: {$constraint}.";
610 throw new LogicException( $msg );
611 }
612
613 return Semver::satisfies( $this->loaded[$name]['version'], $constraint );
614 }
615
621 public function getAttribute( $name ) {
622 if ( isset( $this->testAttributes[$name] ) ) {
623 return $this->testAttributes[$name];
624 }
625
626 if ( in_array( $name, self::LAZY_LOADED_ATTRIBUTES, true ) ) {
627 return $this->getLazyLoadedAttribute( $name );
628 }
629
630 return $this->attributes[$name] ?? [];
631 }
632
641 protected function getLazyLoadedAttribute( $name ) {
642 if ( isset( $this->testAttributes[$name] ) ) {
643 return $this->testAttributes[$name];
644 }
645 if ( isset( $this->lazyAttributes[$name] ) ) {
646 return $this->lazyAttributes[$name];
647 }
648
649 // See if it's in the cache
650 $cache = $this->getCache();
651 $key = $this->makeCacheKey( $cache, 'lazy-attrib', $name );
652 $data = $cache->get( $key );
653 if ( $data !== false ) {
654 $this->lazyAttributes[$name] = $data;
655
656 return $data;
657 }
658
659 $paths = [];
660 foreach ( $this->loaded as $info ) {
661 // mtime (array value) doesn't matter here since
662 // we're skipping cache, so use a dummy time
663 $paths[$info['path']] = 1;
664 }
665
666 $result = $this->readFromQueue( $paths );
667 $data = $result['attributes'][$name] ?? [];
668 $this->saveToCache( $cache, $result );
669 $this->lazyAttributes[$name] = $data;
670
671 return $data;
672 }
673
683 public function setAttributeForTest( $name, array $value ) {
684 // @codeCoverageIgnoreStart
685 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
686 throw new LogicException( __METHOD__ . ' can only be used in tests' );
687 }
688 // @codeCoverageIgnoreEnd
689 if ( isset( $this->testAttributes[$name] ) ) {
690 throw new InvalidArgumentException( "The attribute '$name' has already been overridden" );
691 }
692 $this->testAttributes[$name] = $value;
693
694 return new ScopedCallback( function () use ( $name ) {
695 unset( $this->testAttributes[$name] );
696 } );
697 }
698
704 public function getAllThings() {
705 return $this->loaded;
706 }
707
716 protected static function processAutoLoader( $dir, array $files ) {
717 // Make paths absolute, relative to the JSON file
718 foreach ( $files as &$file ) {
719 $file = "$dir/$file";
720 }
721
722 return $files;
723 }
724
730 public function setSettingsBuilder( SettingsBuilder $settingsBuilder ) {
731 $this->settingsBuilder = $settingsBuilder;
732 }
733
734 private function getSettingsBuilder(): SettingsBuilder {
735 if ( $this->settingsBuilder === null ) {
736 $this->settingsBuilder = SettingsBuilder::getInstance();
737 }
738
739 return $this->settingsBuilder;
740 }
741}
742
744class_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.
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.
Class representing a cache/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.