MediaWiki master
ExtensionRegistry.php
Go to the documentation of this file.
1<?php
2
3use Composer\Semver\Semver;
9use Wikimedia\ScopedCallback;
10
26
30 public const MEDIAWIKI_CORE = 'MediaWiki';
31
36 public const MANIFEST_VERSION = 2;
37
42 public const MANIFEST_VERSION_MW_VERSION = '>= 1.29.0';
43
47 public const OLDEST_MANIFEST_VERSION = 1;
48
52 private const CACHE_VERSION = 8;
53
54 private const CACHE_EXPIRY = 60 * 60 * 24;
55
61 public const MERGE_STRATEGY = '_merge_strategy';
62
66 private const LAZY_LOADED_ATTRIBUTES = [
67 'TrackingCategories',
68 'QUnitTestModules',
69 'SkinLessImportPaths',
70 ];
71
83 private $loaded = [];
84
90 protected $queued = [];
91
97 private $finished = false;
98
105 protected $attributes = [];
106
112 protected $testAttributes = [];
113
119 protected $lazyAttributes = [];
120
126 private $varyHash;
127
133 protected $checkDev = false;
134
141
145 private static $instance;
146
150 private $cache = null;
151
155 private ?SettingsBuilder $settingsBuilder = null;
156
157 private static bool $accessDisabledForUnitTests = false;
158
163 public static function getInstance() {
164 if ( self::$accessDisabledForUnitTests ) {
165 throw new RuntimeException( 'Access is disabled in unit tests' );
166 }
167 if ( self::$instance === null ) {
168 self::$instance = new self();
169 }
170
171 return self::$instance;
172 }
173
177 public static function disableForTest(): void {
178 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
179 throw new RuntimeException( 'Can only be called in tests' );
180 }
181 self::$accessDisabledForUnitTests = true;
182 }
183
187 public static function enableForTest(): void {
188 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
189 throw new RuntimeException( 'Can only be called in tests' );
190 }
191 self::$accessDisabledForUnitTests = false;
192 }
193
202 public function setCache( BagOStuff $cache ): void {
203 $this->cache = $cache;
204 }
205
210 public function setCheckDevRequires( $check ) {
211 $this->checkDev = $check;
212 $this->invalidateProcessCache();
213 }
214
222 public function setLoadTestClassesAndNamespaces( $load ) {
223 $this->loadTestClassesAndNamespaces = $load;
224 }
225
229 public function queue( $path ) {
231
232 $mtime = $wgExtensionInfoMTime;
233 if ( $mtime === false ) {
234 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
235 $mtime = @filemtime( $path );
236 // @codeCoverageIgnoreStart
237 if ( $mtime === false ) {
238 $err = error_get_last();
239 throw new MissingExtensionException( $path, $err['message'] );
240 // @codeCoverageIgnoreEnd
241 }
242 }
243 $this->queued[$path] = $mtime;
244 $this->invalidateProcessCache();
245 }
246
247 private function getCache(): BagOStuff {
248 if ( !$this->cache ) {
249 // NOTE: Copy of ObjectCacheFactory::getDefaultKeyspace
250 //
251 // Can't call MediaWikiServices here, as we must not cause services
252 // to be instantiated before extensions have loaded.
253 global $wgCachePrefix;
254 $keyspace = ( is_string( $wgCachePrefix ) && $wgCachePrefix !== '' )
256 : WikiMap::getCurrentWikiDbDomain()->getId();
257 return ObjectCacheFactory::makeLocalServerCache( $keyspace );
258 }
259
260 return $this->cache;
261 }
262
263 private function makeCacheKey( BagOStuff $cache, $component, ...$extra ) {
264 // Allow reusing cached ExtensionRegistry metadata between wikis (T274648)
265 return $cache->makeGlobalKey(
266 "registration-$component",
267 $this->getVaryHash(),
268 ...$extra
269 );
270 }
271
277 private function getVaryHash() {
278 if ( $this->varyHash === null ) {
279 // We vary the cache on the current queue (what will be or already was loaded)
280 // plus various versions of stuff for VersionChecker
281 $vary = [
282 'registration' => self::CACHE_VERSION,
283 'mediawiki' => MW_VERSION,
284 'abilities' => $this->getAbilities(),
285 'checkDev' => $this->checkDev,
286 'queue' => $this->queued,
287 ];
288 $this->varyHash = md5( json_encode( $vary ) );
289 }
290 return $this->varyHash;
291 }
292
296 private function invalidateProcessCache() {
297 $this->varyHash = null;
298 $this->lazyAttributes = [];
299 }
300
301 public function loadFromQueue() {
302 if ( !$this->queued ) {
303 return;
304 }
305
306 if ( $this->finished ) {
307 throw new LogicException(
308 "The following paths tried to load late: "
309 . implode( ', ', array_keys( $this->queued ) )
310 );
311 }
312
313 $cache = $this->getCache();
314 // See if this queue is in APC
315 $key = $this->makeCacheKey( $cache, 'main' );
316 $data = $cache->get( $key );
317 if ( !$data ) {
318 $data = $this->readFromQueue( $this->queued );
319 $this->saveToCache( $cache, $data );
320 }
321 $this->exportExtractedData( $data );
322 }
323
330 protected function saveToCache( BagOStuff $cache, array $data ) {
332 if ( $data['warnings'] && $wgDevelopmentWarnings ) {
333 // If warnings were shown, don't cache it
334 return;
335 }
336 $lazy = [];
337 // Cache lazy-loaded attributes separately
338 foreach ( self::LAZY_LOADED_ATTRIBUTES as $attrib ) {
339 if ( isset( $data['attributes'][$attrib] ) ) {
340 $lazy[$attrib] = $data['attributes'][$attrib];
341 unset( $data['attributes'][$attrib] );
342 }
343 }
344 $mainKey = $this->makeCacheKey( $cache, 'main' );
345 $cache->set( $mainKey, $data, self::CACHE_EXPIRY );
346 foreach ( $lazy as $attrib => $value ) {
347 $cache->set(
348 $this->makeCacheKey( $cache, 'lazy-attrib', $attrib ),
349 $value,
350 self::CACHE_EXPIRY
351 );
352 }
353 }
354
361 public function getQueue() {
362 return $this->queued;
363 }
364
369 public function clearQueue() {
370 $this->queued = [];
371 $this->invalidateProcessCache();
372 }
373
379 public function finish() {
380 $this->finished = true;
381 }
382
387 private function getAbilities() {
388 return [
389 'shell' => !Shell::isDisabled(),
390 ];
391 }
392
398 private function buildVersionChecker() {
399 // array to optionally specify more verbose error messages for
400 // missing abilities
401 $abilityErrors = [
402 'shell' => ( new ShellDisabledError() )->getMessage(),
403 ];
404
405 return new VersionChecker(
407 PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION,
408 get_loaded_extensions(),
409 $this->getAbilities(),
410 $abilityErrors
411 );
412 }
413
424 public function readFromQueue( array $queue ) {
425 $processor = new ExtensionProcessor();
426 $versionChecker = $this->buildVersionChecker();
427 $extDependencies = [];
428 $warnings = false;
429 foreach ( $queue as $path => $mtime ) {
430 $json = file_get_contents( $path );
431 if ( $json === false ) {
432 throw new InvalidArgumentException( "Unable to read $path, does it exist?" );
433 }
434 $info = json_decode( $json, /* $assoc = */ true );
435 if ( !is_array( $info ) ) {
436 throw new InvalidArgumentException( "$path is not a valid JSON file." );
437 }
438
439 $version = $info['manifest_version'];
440 if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) {
441 throw new InvalidArgumentException( "$path: unsupported manifest_version: {$version}" );
442 }
443
444 // get all requirements/dependencies for this extension
445 $requires = $processor->getRequirements( $info, $this->checkDev );
446
447 // validate the information needed and add the requirements
448 if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) {
449 $extDependencies[$info['name']] = $requires;
450 }
451
452 // Compatible, read and extract info
453 $processor->extractInfo( $path, $info, $version );
454 }
455 $data = $processor->getExtractedInfo( $this->loadTestClassesAndNamespaces );
456 $data['warnings'] = $warnings;
457
458 // check for incompatible extensions
459 $incompatible = $versionChecker
460 ->setLoadedExtensionsAndSkins( $data['credits'] )
461 ->checkArray( $extDependencies );
462
463 if ( $incompatible ) {
464 throw new ExtensionDependencyError( $incompatible );
465 }
466
467 return $data;
468 }
469
470 protected function exportExtractedData( array $info ) {
471 if ( $info['globals'] ) {
472 // Create a copy of the keys to allow fast access via isset also for null values
473 // Since php8.1 always a read-only copy is created when the whole object is passed on function calls
474 // (like for array_key_exists). See T366547 - https://wiki.php.net/rfc/restrict_globals_usage
475 $knownGlobals = array_fill_keys( array_keys( $GLOBALS ), true );
476
477 foreach ( $info['globals'] as $key => $val ) {
478 // If a merge strategy is set, read it and remove it from the value
479 // so it doesn't accidentally end up getting set.
480 if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) {
481 $mergeStrategy = $val[self::MERGE_STRATEGY];
482 unset( $val[self::MERGE_STRATEGY] );
483 } else {
484 $mergeStrategy = 'array_merge';
485 }
486
487 if ( $mergeStrategy === 'provide_default' ) {
488 if ( !isset( $knownGlobals[$key] ) ) {
489 $GLOBALS[$key] = $val;
490 $knownGlobals[$key] = true;
491 }
492 continue;
493 }
494
495 // Performance optimization: When the global doesn't exist (not even with null), just set it
496 if ( !isset( $knownGlobals[$key] ) ) {
497 $GLOBALS[$key] = $val;
498 $knownGlobals[$key] = true;
499 continue;
500 } elseif ( !is_array( $val ) || !is_array( $GLOBALS[$key] ) ) {
501 // When at least one of the global value and the default is not an array, the merge
502 // strategy is ignored and the global value will simply override the default.
503 continue;
504 } elseif ( !$GLOBALS[$key] ) {
505 // Performance optimization: When the target is an empty array, just set it
506 $GLOBALS[$key] = $val;
507 continue;
508 }
509
510 switch ( $mergeStrategy ) {
511 case 'array_merge_recursive':
512 $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
513 break;
514 case 'array_replace_recursive':
515 $GLOBALS[$key] = array_replace_recursive( $val, $GLOBALS[$key] );
516 break;
517 case 'array_plus_2d':
518 $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val );
519 break;
520 case 'array_plus':
521 $GLOBALS[$key] += $val;
522 break;
523 case 'array_merge':
524 $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] );
525 break;
526 default:
527 throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" );
528 }
529 }
530 }
531
532 if ( isset( $info['autoloaderNS'] ) ) {
533 AutoLoader::registerNamespaces( $info['autoloaderNS'] );
534 }
535
536 if ( isset( $info['autoloaderClasses'] ) ) {
537 AutoLoader::registerClasses( $info['autoloaderClasses'] );
538 }
539
540 foreach ( $info['defines'] as $name => $val ) {
541 if ( !defined( $name ) ) {
542 define( $name, $val );
543 } elseif ( constant( $name ) !== $val ) {
544 throw new UnexpectedValueException(
545 "$name cannot be re-defined with $val it has already been set with " . constant( $name )
546 );
547 }
548 }
549
550 if ( isset( $info['autoloaderPaths'] ) ) {
551 AutoLoader::loadFiles( $info['autoloaderPaths'] );
552 }
553
554 $this->loaded += $info['credits'];
555 if ( $info['attributes'] ) {
556 if ( !$this->attributes ) {
557 $this->attributes = $info['attributes'];
558 } else {
559 $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
560 }
561 }
562
563 // XXX: SettingsBuilder should really be a parameter to this method.
564 $settings = $this->getSettingsBuilder();
565
566 foreach ( $info['callbacks'] as $name => $cb ) {
567 if ( !is_callable( $cb ) ) {
568 if ( is_array( $cb ) ) {
569 $cb = '[ ' . implode( ', ', $cb ) . ' ]';
570 }
571 throw new UnexpectedValueException( "callback '$cb' is not callable" );
572 }
573 $cb( $info['credits'][$name], $settings );
574 }
575 }
576
585 public function isLoaded( $name, $constraint = '*' ) {
586 $isLoaded = isset( $this->loaded[$name] );
587 if ( $constraint === '*' || !$isLoaded ) {
588 return $isLoaded;
589 }
590 // if a specific constraint is requested, but no version is set, throw an exception
591 if ( !isset( $this->loaded[$name]['version'] ) ) {
592 $msg = "{$name} does not expose its version, but an extension or a skin"
593 . " requires: {$constraint}.";
594 throw new LogicException( $msg );
595 }
596
597 return Semver::satisfies( $this->loaded[$name]['version'], $constraint );
598 }
599
604 public function getAttribute( $name ) {
605 if ( isset( $this->testAttributes[$name] ) ) {
606 return $this->testAttributes[$name];
607 }
608
609 if ( in_array( $name, self::LAZY_LOADED_ATTRIBUTES, true ) ) {
610 return $this->getLazyLoadedAttribute( $name );
611 }
612
613 return $this->attributes[$name] ?? [];
614 }
615
622 protected function getLazyLoadedAttribute( $name ) {
623 if ( isset( $this->testAttributes[$name] ) ) {
624 return $this->testAttributes[$name];
625 }
626 if ( isset( $this->lazyAttributes[$name] ) ) {
627 return $this->lazyAttributes[$name];
628 }
629
630 // See if it's in the cache
631 $cache = $this->getCache();
632 $key = $this->makeCacheKey( $cache, 'lazy-attrib', $name );
633 $data = $cache->get( $key );
634 if ( $data !== false ) {
635 $this->lazyAttributes[$name] = $data;
636 return $data;
637 }
638
639 $paths = [];
640 foreach ( $this->loaded as $info ) {
641 // mtime (array value) doesn't matter here since
642 // we're skipping cache, so use a dummy time
643 $paths[$info['path']] = 1;
644 }
645
646 $result = $this->readFromQueue( $paths );
647 $data = $result['attributes'][$name] ?? [];
648 $this->saveToCache( $cache, $result );
649 $this->lazyAttributes[$name] = $data;
650
651 return $data;
652 }
653
662 public function setAttributeForTest( $name, array $value ) {
663 // @codeCoverageIgnoreStart
664 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
665 throw new LogicException( __METHOD__ . ' can only be used in tests' );
666 }
667 // @codeCoverageIgnoreEnd
668 if ( isset( $this->testAttributes[$name] ) ) {
669 throw new InvalidArgumentException( "The attribute '$name' has already been overridden" );
670 }
671 $this->testAttributes[$name] = $value;
672 return new ScopedCallback( function () use ( $name ) {
673 unset( $this->testAttributes[$name] );
674 } );
675 }
676
682 public function getAllThings() {
683 return $this->loaded;
684 }
685
693 protected static function processAutoLoader( $dir, array $files ) {
694 // Make paths absolute, relative to the JSON file
695 foreach ( $files as &$file ) {
696 $file = "$dir/$file";
697 }
698 return $files;
699 }
700
705 public function setSettingsBuilder( SettingsBuilder $settingsBuilder ) {
706 $this->settingsBuilder = $settingsBuilder;
707 }
708
709 private function getSettingsBuilder(): SettingsBuilder {
710 if ( $this->settingsBuilder === null ) {
711 $this->settingsBuilder = SettingsBuilder::getInstance();
712 }
713 return $this->settingsBuilder;
714 }
715}
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
Load extension manifests and then aggregate their contents.
Load JSON files, and uses a Processor to extract information.
setLoadTestClassesAndNamespaces( $load)
Controls if classes and namespaces defined under the keys TestAutoloadClasses and TestAutoloadNamespa...
static processAutoLoader( $dir, array $files)
Fully expand autoloader paths.
isLoaded( $name, $constraint=' *')
Whether a thing has been loaded.
const MERGE_STRATEGY
Special key that defines the merge strategy.
getQueue()
Get the current load queue.
getLazyLoadedAttribute( $name)
Get an attribute value that isn't cached by reading each extension.json file again.
setSettingsBuilder(SettingsBuilder $settingsBuilder)
saveToCache(BagOStuff $cache, array $data)
Save data in the cache.
const MANIFEST_VERSION
Version of the highest supported manifest version Note: Update MANIFEST_VERSION_MW_VERSION when chang...
setAttributeForTest( $name, array $value)
Force override the value of an attribute during tests.
const OLDEST_MANIFEST_VERSION
Version of the oldest supported manifest version.
array $testAttributes
Attributes for testing.
clearQueue()
Clear the current load queue.
array $lazyAttributes
Lazy-loaded attributes.
setCache(BagOStuff $cache)
Set the cache to use for extension info.
const MANIFEST_VERSION_MW_VERSION
MediaWiki version constraint representing what the current highest MANIFEST_VERSION is supported in.
int[] $queued
List of paths that should be loaded.
const MEDIAWIKI_CORE
"requires" key that applies to MediaWiki core
bool $loadTestClassesAndNamespaces
Whether test classes and namespaces should be added to the auto loader.
readFromQueue(array $queue)
Process a queue of extensions and return their extracted data.
exportExtractedData(array $info)
getAllThings()
Get credits information about all installed extensions and skins.
finish()
After this is called, no more extensions can be loaded.
array $attributes
Items in the JSON file that aren't being set as globals.
bool $checkDev
Whether to check dev-requires.
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
Thrown when ExtensionRegistry cannot open the extension.json or skin.json file.
Check whether extensions and their dependencies meet certain version requirements.
Class representing a cache/ephemeral data store.
Definition BagOStuff.php:88
set( $key, $value, $exptime=0, $flags=0)
Set an item.
get( $key, $flags=0)
Get an item.
makeGlobalKey( $keygroup,... $components)
Make a cache key from the given components, in the "global" keyspace.
$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.