MediaWiki master
ExtensionRegistry.php
Go to the documentation of this file.
1<?php
2
3use Composer\Semver\Semver;
8use Wikimedia\ScopedCallback;
9
25
29 public const MEDIAWIKI_CORE = 'MediaWiki';
30
35 public const MANIFEST_VERSION = 2;
36
41 public const MANIFEST_VERSION_MW_VERSION = '>= 1.29.0';
42
46 public const OLDEST_MANIFEST_VERSION = 1;
47
51 private const CACHE_VERSION = 8;
52
53 private const CACHE_EXPIRY = 60 * 60 * 24;
54
60 public const MERGE_STRATEGY = '_merge_strategy';
61
65 private const LAZY_LOADED_ATTRIBUTES = [
66 'TrackingCategories',
67 'QUnitTestModules',
68 'SkinLessImportPaths',
69 ];
70
82 private $loaded = [];
83
89 protected $queued = [];
90
96 private $finished = false;
97
104 protected $attributes = [];
105
111 protected $testAttributes = [];
112
118 protected $lazyAttributes = [];
119
125 private $varyHash;
126
132 protected $checkDev = false;
133
140
144 private static $instance;
145
149 private $cache = null;
150
154 private ?SettingsBuilder $settingsBuilder = null;
155
156 private static bool $accessDisabledForUnitTests = false;
157
162 public static function getInstance() {
163 if ( self::$accessDisabledForUnitTests ) {
164 throw new RuntimeException( 'Access is disabled in unit tests' );
165 }
166 if ( self::$instance === null ) {
167 self::$instance = new self();
168 }
169
170 return self::$instance;
171 }
172
176 public static function disableForTest(): void {
177 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
178 throw new RuntimeException( 'Can only be called in tests' );
179 }
180 self::$accessDisabledForUnitTests = true;
181 }
182
186 public static function enableForTest(): void {
187 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
188 throw new RuntimeException( 'Can only be called in tests' );
189 }
190 self::$accessDisabledForUnitTests = false;
191 }
192
201 public function setCache( BagOStuff $cache ): void {
202 $this->cache = $cache;
203 }
204
209 public function setCheckDevRequires( $check ) {
210 $this->checkDev = $check;
211 $this->invalidateProcessCache();
212 }
213
221 public function setLoadTestClassesAndNamespaces( $load ) {
222 $this->loadTestClassesAndNamespaces = $load;
223 }
224
228 public function queue( $path ) {
230
231 $mtime = $wgExtensionInfoMTime;
232 if ( $mtime === false ) {
233 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
234 $mtime = @filemtime( $path );
235 // @codeCoverageIgnoreStart
236 if ( $mtime === false ) {
237 $err = error_get_last();
238 throw new MissingExtensionException( $path, $err['message'] );
239 // @codeCoverageIgnoreEnd
240 }
241 }
242 $this->queued[$path] = $mtime;
243 $this->invalidateProcessCache();
244 }
245
246 private function getCache(): BagOStuff {
247 if ( !$this->cache ) {
248 // NOTE: Copy of ObjectCacheFactory::getDefaultKeyspace
249 //
250 // Can't call MediaWikiServices here, as we must not cause services
251 // to be instantiated before extensions have loaded.
252 global $wgCachePrefix;
253 $keyspace = ( is_string( $wgCachePrefix ) && $wgCachePrefix !== '' )
255 : WikiMap::getCurrentWikiDbDomain()->getId();
256 return ObjectCacheFactory::makeLocalServerCache( $keyspace );
257 }
258
259 return $this->cache;
260 }
261
262 private function makeCacheKey( BagOStuff $cache, $component, ...$extra ) {
263 // Allow reusing cached ExtensionRegistry metadata between wikis (T274648)
264 return $cache->makeGlobalKey(
265 "registration-$component",
266 $this->getVaryHash(),
267 ...$extra
268 );
269 }
270
276 private function getVaryHash() {
277 if ( $this->varyHash === null ) {
278 // We vary the cache on the current queue (what will be or already was loaded)
279 // plus various versions of stuff for VersionChecker
280 $vary = [
281 'registration' => self::CACHE_VERSION,
282 'mediawiki' => MW_VERSION,
283 'abilities' => $this->getAbilities(),
284 'checkDev' => $this->checkDev,
285 'queue' => $this->queued,
286 ];
287 $this->varyHash = md5( json_encode( $vary ) );
288 }
289 return $this->varyHash;
290 }
291
295 private function invalidateProcessCache() {
296 $this->varyHash = null;
297 $this->lazyAttributes = [];
298 }
299
300 public function loadFromQueue() {
301 if ( !$this->queued ) {
302 return;
303 }
304
305 if ( $this->finished ) {
306 throw new LogicException(
307 "The following paths tried to load late: "
308 . implode( ', ', array_keys( $this->queued ) )
309 );
310 }
311
312 $cache = $this->getCache();
313 // See if this queue is in APC
314 $key = $this->makeCacheKey( $cache, 'main' );
315 $data = $cache->get( $key );
316 if ( !$data ) {
317 $data = $this->readFromQueue( $this->queued );
318 $this->saveToCache( $cache, $data );
319 }
320 $this->exportExtractedData( $data );
321 }
322
329 protected function saveToCache( BagOStuff $cache, array $data ) {
331 if ( $data['warnings'] && $wgDevelopmentWarnings ) {
332 // If warnings were shown, don't cache it
333 return;
334 }
335 $lazy = [];
336 // Cache lazy-loaded attributes separately
337 foreach ( self::LAZY_LOADED_ATTRIBUTES as $attrib ) {
338 if ( isset( $data['attributes'][$attrib] ) ) {
339 $lazy[$attrib] = $data['attributes'][$attrib];
340 unset( $data['attributes'][$attrib] );
341 }
342 }
343 $mainKey = $this->makeCacheKey( $cache, 'main' );
344 $cache->set( $mainKey, $data, self::CACHE_EXPIRY );
345 foreach ( $lazy as $attrib => $value ) {
346 $cache->set(
347 $this->makeCacheKey( $cache, 'lazy-attrib', $attrib ),
348 $value,
349 self::CACHE_EXPIRY
350 );
351 }
352 }
353
360 public function getQueue() {
361 return $this->queued;
362 }
363
368 public function clearQueue() {
369 $this->queued = [];
370 $this->invalidateProcessCache();
371 }
372
378 public function finish() {
379 $this->finished = true;
380 }
381
386 private function getAbilities() {
387 return [
388 'shell' => !Shell::isDisabled(),
389 ];
390 }
391
397 private function buildVersionChecker() {
398 // array to optionally specify more verbose error messages for
399 // missing abilities
400 $abilityErrors = [
401 'shell' => ( new ShellDisabledError() )->getMessage(),
402 ];
403
404 return new VersionChecker(
406 PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION,
407 get_loaded_extensions(),
408 $this->getAbilities(),
409 $abilityErrors
410 );
411 }
412
423 public function readFromQueue( array $queue ) {
424 $processor = new ExtensionProcessor();
425 $versionChecker = $this->buildVersionChecker();
426 $extDependencies = [];
427 $warnings = false;
428 foreach ( $queue as $path => $mtime ) {
429 $json = file_get_contents( $path );
430 if ( $json === false ) {
431 throw new InvalidArgumentException( "Unable to read $path, does it exist?" );
432 }
433 $info = json_decode( $json, /* $assoc = */ true );
434 if ( !is_array( $info ) ) {
435 throw new InvalidArgumentException( "$path is not a valid JSON file." );
436 }
437
438 $version = $info['manifest_version'];
439 if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) {
440 throw new InvalidArgumentException( "$path: unsupported manifest_version: {$version}" );
441 }
442
443 // get all requirements/dependencies for this extension
444 $requires = $processor->getRequirements( $info, $this->checkDev );
445
446 // validate the information needed and add the requirements
447 if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) {
448 $extDependencies[$info['name']] = $requires;
449 }
450
451 // Compatible, read and extract info
452 $processor->extractInfo( $path, $info, $version );
453 }
454 $data = $processor->getExtractedInfo( $this->loadTestClassesAndNamespaces );
455 $data['warnings'] = $warnings;
456
457 // check for incompatible extensions
458 $incompatible = $versionChecker
459 ->setLoadedExtensionsAndSkins( $data['credits'] )
460 ->checkArray( $extDependencies );
461
462 if ( $incompatible ) {
463 throw new ExtensionDependencyError( $incompatible );
464 }
465
466 return $data;
467 }
468
469 protected function exportExtractedData( array $info ) {
470 foreach ( $info['globals'] as $key => $val ) {
471 // If a merge strategy is set, read it and remove it from the value
472 // so it doesn't accidentally end up getting set.
473 if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) {
474 $mergeStrategy = $val[self::MERGE_STRATEGY];
475 unset( $val[self::MERGE_STRATEGY] );
476 } else {
477 $mergeStrategy = 'array_merge';
478 }
479
480 if ( $mergeStrategy === 'provide_default' ) {
481 if ( !array_key_exists( $key, $GLOBALS ) ) {
482 $GLOBALS[$key] = $val;
483 }
484 continue;
485 }
486
487 // When at least one of the global value and the default is not an array, the merge
488 // strategy is ignored and the global value will simply override the default.
489 if ( array_key_exists( $key, $GLOBALS ) && ( !is_array( $GLOBALS[$key] ) || !is_array( $val ) ) ) {
490 continue;
491 }
492
493 // Optimistic: If the global is not set, or is an empty array, replace it entirely.
494 // Will be O(1) performance.
495 if ( !array_key_exists( $key, $GLOBALS ) || ( is_array( $GLOBALS[$key] ) && !$GLOBALS[$key] ) ) {
496 $GLOBALS[$key] = $val;
497 continue;
498 }
499
500 switch ( $mergeStrategy ) {
501 case 'array_merge_recursive':
502 $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
503 break;
504 case 'array_replace_recursive':
505 $GLOBALS[$key] = array_replace_recursive( $val, $GLOBALS[$key] );
506 break;
507 case 'array_plus_2d':
508 $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val );
509 break;
510 case 'array_plus':
511 $GLOBALS[$key] += $val;
512 break;
513 case 'array_merge':
514 $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] );
515 break;
516 default:
517 throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" );
518 }
519 }
520
521 if ( isset( $info['autoloaderNS'] ) ) {
522 AutoLoader::registerNamespaces( $info['autoloaderNS'] );
523 }
524
525 if ( isset( $info['autoloaderClasses'] ) ) {
526 AutoLoader::registerClasses( $info['autoloaderClasses'] );
527 }
528
529 foreach ( $info['defines'] as $name => $val ) {
530 if ( !defined( $name ) ) {
531 define( $name, $val );
532 } elseif ( constant( $name ) !== $val ) {
533 throw new UnexpectedValueException(
534 "$name cannot be re-defined with $val it has already been set with " . constant( $name )
535 );
536 }
537 }
538
539 if ( isset( $info['autoloaderPaths'] ) ) {
540 AutoLoader::loadFiles( $info['autoloaderPaths'] );
541 }
542
543 $this->loaded += $info['credits'];
544 if ( $info['attributes'] ) {
545 if ( !$this->attributes ) {
546 $this->attributes = $info['attributes'];
547 } else {
548 $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
549 }
550 }
551
552 // XXX: SettingsBuilder should really be a parameter to this method.
553 $settings = $this->getSettingsBuilder();
554
555 foreach ( $info['callbacks'] as $name => $cb ) {
556 if ( !is_callable( $cb ) ) {
557 if ( is_array( $cb ) ) {
558 $cb = '[ ' . implode( ', ', $cb ) . ' ]';
559 }
560 throw new UnexpectedValueException( "callback '$cb' is not callable" );
561 }
562 $cb( $info['credits'][$name], $settings );
563 }
564 }
565
574 public function isLoaded( $name, $constraint = '*' ) {
575 $isLoaded = isset( $this->loaded[$name] );
576 if ( $constraint === '*' || !$isLoaded ) {
577 return $isLoaded;
578 }
579 // if a specific constraint is requested, but no version is set, throw an exception
580 if ( !isset( $this->loaded[$name]['version'] ) ) {
581 $msg = "{$name} does not expose its version, but an extension or a skin"
582 . " requires: {$constraint}.";
583 throw new LogicException( $msg );
584 }
585
586 return Semver::satisfies( $this->loaded[$name]['version'], $constraint );
587 }
588
593 public function getAttribute( $name ) {
594 if ( isset( $this->testAttributes[$name] ) ) {
595 return $this->testAttributes[$name];
596 }
597
598 if ( in_array( $name, self::LAZY_LOADED_ATTRIBUTES, true ) ) {
599 return $this->getLazyLoadedAttribute( $name );
600 }
601
602 return $this->attributes[$name] ?? [];
603 }
604
611 protected function getLazyLoadedAttribute( $name ) {
612 if ( isset( $this->testAttributes[$name] ) ) {
613 return $this->testAttributes[$name];
614 }
615 if ( isset( $this->lazyAttributes[$name] ) ) {
616 return $this->lazyAttributes[$name];
617 }
618
619 // See if it's in the cache
620 $cache = $this->getCache();
621 $key = $this->makeCacheKey( $cache, 'lazy-attrib', $name );
622 $data = $cache->get( $key );
623 if ( $data !== false ) {
624 $this->lazyAttributes[$name] = $data;
625 return $data;
626 }
627
628 $paths = [];
629 foreach ( $this->loaded as $info ) {
630 // mtime (array value) doesn't matter here since
631 // we're skipping cache, so use a dummy time
632 $paths[$info['path']] = 1;
633 }
634
635 $result = $this->readFromQueue( $paths );
636 $data = $result['attributes'][$name] ?? [];
637 $this->saveToCache( $cache, $result );
638 $this->lazyAttributes[$name] = $data;
639
640 return $data;
641 }
642
651 public function setAttributeForTest( $name, array $value ) {
652 // @codeCoverageIgnoreStart
653 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
654 throw new LogicException( __METHOD__ . ' can only be used in tests' );
655 }
656 // @codeCoverageIgnoreEnd
657 if ( isset( $this->testAttributes[$name] ) ) {
658 throw new InvalidArgumentException( "The attribute '$name' has already been overridden" );
659 }
660 $this->testAttributes[$name] = $value;
661 return new ScopedCallback( function () use ( $name ) {
662 unset( $this->testAttributes[$name] );
663 } );
664 }
665
671 public function getAllThings() {
672 return $this->loaded;
673 }
674
682 protected static function processAutoLoader( $dir, array $files ) {
683 // Make paths absolute, relative to the JSON file
684 foreach ( $files as &$file ) {
685 $file = "$dir/$file";
686 }
687 return $files;
688 }
689
694 public function setSettingsBuilder( SettingsBuilder $settingsBuilder ) {
695 $this->settingsBuilder = $settingsBuilder;
696 }
697
698 private function getSettingsBuilder(): SettingsBuilder {
699 if ( $this->settingsBuilder === null ) {
700 $this->settingsBuilder = SettingsBuilder::getInstance();
701 }
702 return $this->settingsBuilder;
703 }
704}
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
Class representing a cache/ephemeral data store.
Definition BagOStuff.php:85
get( $key, $flags=0)
Get an item.
set( $key, $value, $exptime=0, $flags=0)
Set an item.
makeGlobalKey( $keygroup,... $components)
Make a cache key from the given components, in the "global" keyspace.
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.
$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.