MediaWiki master
ExtensionRegistry.php
Go to the documentation of this file.
1<?php
2
3use Composer\Semver\Semver;
7use Wikimedia\ScopedCallback;
8
24
28 public const MEDIAWIKI_CORE = 'MediaWiki';
29
34 public const MANIFEST_VERSION = 2;
35
40 public const MANIFEST_VERSION_MW_VERSION = '>= 1.29.0';
41
45 public const OLDEST_MANIFEST_VERSION = 1;
46
50 private const CACHE_VERSION = 8;
51
52 private const CACHE_EXPIRY = 60 * 60 * 24;
53
59 public const MERGE_STRATEGY = '_merge_strategy';
60
64 private const LAZY_LOADED_ATTRIBUTES = [
65 'TrackingCategories',
66 'QUnitTestModules',
67 'SkinLessImportPaths',
68 ];
69
81 private $loaded = [];
82
88 protected $queued = [];
89
95 private $finished = false;
96
103 protected $attributes = [];
104
110 protected $testAttributes = [];
111
117 protected $lazyAttributes = [];
118
124 private $varyHash;
125
131 protected $checkDev = false;
132
139
143 private static $instance;
144
148 private $cache = null;
149
153 private ?SettingsBuilder $settingsBuilder = null;
154
155 private static bool $accessDisabledForUnitTests = false;
156
161 public static function getInstance() {
162 if ( self::$accessDisabledForUnitTests ) {
163 throw new RuntimeException( 'Access is disabled in unit tests' );
164 }
165 if ( self::$instance === null ) {
166 self::$instance = new self();
167 }
168
169 return self::$instance;
170 }
171
175 public static function disableForTest(): void {
176 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
177 throw new RuntimeException( 'Can only be called in tests' );
178 }
179 self::$accessDisabledForUnitTests = true;
180 }
181
185 public static function enableForTest(): void {
186 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
187 throw new RuntimeException( 'Can only be called in tests' );
188 }
189 self::$accessDisabledForUnitTests = false;
190 }
191
200 public function setCache( BagOStuff $cache ): void {
201 $this->cache = $cache;
202 }
203
208 public function setCheckDevRequires( $check ) {
209 $this->checkDev = $check;
210 $this->invalidateProcessCache();
211 }
212
220 public function setLoadTestClassesAndNamespaces( $load ) {
221 $this->loadTestClassesAndNamespaces = $load;
222 }
223
227 public function queue( $path ) {
229
230 $mtime = $wgExtensionInfoMTime;
231 if ( $mtime === false ) {
232 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
233 $mtime = @filemtime( $path );
234 // @codeCoverageIgnoreStart
235 if ( $mtime === false ) {
236 $err = error_get_last();
237 throw new MissingExtensionException( $path, $err['message'] );
238 // @codeCoverageIgnoreEnd
239 }
240 }
241 $this->queued[$path] = $mtime;
242 $this->invalidateProcessCache();
243 }
244
245 private function getCache(): BagOStuff {
246 if ( !$this->cache ) {
247 // Can't call MediaWikiServices here, as we must not cause services
248 // to be instantiated before extensions have loaded.
249 return ObjectCache::makeLocalServerCache();
250 }
251
252 return $this->cache;
253 }
254
255 private function makeCacheKey( BagOStuff $cache, $component, ...$extra ) {
256 // Allow reusing cached ExtensionRegistry metadata between wikis (T274648)
257 return $cache->makeGlobalKey(
258 "registration-$component",
259 $this->getVaryHash(),
260 ...$extra
261 );
262 }
263
269 private function getVaryHash() {
270 if ( $this->varyHash === null ) {
271 // We vary the cache on the current queue (what will be or already was loaded)
272 // plus various versions of stuff for VersionChecker
273 $vary = [
274 'registration' => self::CACHE_VERSION,
275 'mediawiki' => MW_VERSION,
276 'abilities' => $this->getAbilities(),
277 'checkDev' => $this->checkDev,
278 'queue' => $this->queued,
279 ];
280 $this->varyHash = md5( json_encode( $vary ) );
281 }
282 return $this->varyHash;
283 }
284
288 private function invalidateProcessCache() {
289 $this->varyHash = null;
290 $this->lazyAttributes = [];
291 }
292
293 public function loadFromQueue() {
294 if ( !$this->queued ) {
295 return;
296 }
297
298 if ( $this->finished ) {
299 throw new LogicException(
300 "The following paths tried to load late: "
301 . implode( ', ', array_keys( $this->queued ) )
302 );
303 }
304
305 $cache = $this->getCache();
306 // See if this queue is in APC
307 $key = $this->makeCacheKey( $cache, 'main' );
308 $data = $cache->get( $key );
309 if ( !$data ) {
310 $data = $this->readFromQueue( $this->queued );
311 $this->saveToCache( $cache, $data );
312 }
313 $this->exportExtractedData( $data );
314 }
315
322 protected function saveToCache( BagOStuff $cache, array $data ) {
324 if ( $data['warnings'] && $wgDevelopmentWarnings ) {
325 // If warnings were shown, don't cache it
326 return;
327 }
328 $lazy = [];
329 // Cache lazy-loaded attributes separately
330 foreach ( self::LAZY_LOADED_ATTRIBUTES as $attrib ) {
331 if ( isset( $data['attributes'][$attrib] ) ) {
332 $lazy[$attrib] = $data['attributes'][$attrib];
333 unset( $data['attributes'][$attrib] );
334 }
335 }
336 $mainKey = $this->makeCacheKey( $cache, 'main' );
337 $cache->set( $mainKey, $data, self::CACHE_EXPIRY );
338 foreach ( $lazy as $attrib => $value ) {
339 $cache->set(
340 $this->makeCacheKey( $cache, 'lazy-attrib', $attrib ),
341 $value,
342 self::CACHE_EXPIRY
343 );
344 }
345 }
346
353 public function getQueue() {
354 return $this->queued;
355 }
356
361 public function clearQueue() {
362 $this->queued = [];
363 $this->invalidateProcessCache();
364 }
365
371 public function finish() {
372 $this->finished = true;
373 }
374
379 private function getAbilities() {
380 return [
381 'shell' => !Shell::isDisabled(),
382 ];
383 }
384
390 private function buildVersionChecker() {
391 // array to optionally specify more verbose error messages for
392 // missing abilities
393 $abilityErrors = [
394 'shell' => ( new ShellDisabledError() )->getMessage(),
395 ];
396
397 return new VersionChecker(
399 PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION,
400 get_loaded_extensions(),
401 $this->getAbilities(),
402 $abilityErrors
403 );
404 }
405
416 public function readFromQueue( array $queue ) {
417 $processor = new ExtensionProcessor();
418 $versionChecker = $this->buildVersionChecker();
419 $extDependencies = [];
420 $warnings = false;
421 foreach ( $queue as $path => $mtime ) {
422 $json = file_get_contents( $path );
423 if ( $json === false ) {
424 throw new InvalidArgumentException( "Unable to read $path, does it exist?" );
425 }
426 $info = json_decode( $json, /* $assoc = */ true );
427 if ( !is_array( $info ) ) {
428 throw new InvalidArgumentException( "$path is not a valid JSON file." );
429 }
430
431 $version = $info['manifest_version'];
432 if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) {
433 throw new InvalidArgumentException( "$path: unsupported manifest_version: {$version}" );
434 }
435
436 // get all requirements/dependencies for this extension
437 $requires = $processor->getRequirements( $info, $this->checkDev );
438
439 // validate the information needed and add the requirements
440 if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) {
441 $extDependencies[$info['name']] = $requires;
442 }
443
444 // Compatible, read and extract info
445 $processor->extractInfo( $path, $info, $version );
446 }
447 $data = $processor->getExtractedInfo( $this->loadTestClassesAndNamespaces );
448 $data['warnings'] = $warnings;
449
450 // check for incompatible extensions
451 $incompatible = $versionChecker
452 ->setLoadedExtensionsAndSkins( $data['credits'] )
453 ->checkArray( $extDependencies );
454
455 if ( $incompatible ) {
456 throw new ExtensionDependencyError( $incompatible );
457 }
458
459 return $data;
460 }
461
462 protected function exportExtractedData( array $info ) {
463 foreach ( $info['globals'] as $key => $val ) {
464 // If a merge strategy is set, read it and remove it from the value
465 // so it doesn't accidentally end up getting set.
466 if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) {
467 $mergeStrategy = $val[self::MERGE_STRATEGY];
468 unset( $val[self::MERGE_STRATEGY] );
469 } else {
470 $mergeStrategy = 'array_merge';
471 }
472
473 if ( $mergeStrategy === 'provide_default' ) {
474 if ( !array_key_exists( $key, $GLOBALS ) ) {
475 $GLOBALS[$key] = $val;
476 }
477 continue;
478 }
479
480 // Optimistic: If the global is not set, or is an empty array, replace it entirely.
481 // Will be O(1) performance.
482 if ( !array_key_exists( $key, $GLOBALS ) || ( is_array( $GLOBALS[$key] ) && !$GLOBALS[$key] ) ) {
483 $GLOBALS[$key] = $val;
484 continue;
485 }
486
487 if ( !is_array( $GLOBALS[$key] ) || !is_array( $val ) ) {
488 // config setting that has already been overridden, don't set it
489 continue;
490 }
491
492 switch ( $mergeStrategy ) {
493 case 'array_merge_recursive':
494 $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
495 break;
496 case 'array_replace_recursive':
497 $GLOBALS[$key] = array_replace_recursive( $val, $GLOBALS[$key] );
498 break;
499 case 'array_plus_2d':
500 $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val );
501 break;
502 case 'array_plus':
503 $GLOBALS[$key] += $val;
504 break;
505 case 'array_merge':
506 $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] );
507 break;
508 default:
509 throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" );
510 }
511 }
512
513 if ( isset( $info['autoloaderNS'] ) ) {
514 AutoLoader::registerNamespaces( $info['autoloaderNS'] );
515 }
516
517 if ( isset( $info['autoloaderClasses'] ) ) {
518 AutoLoader::registerClasses( $info['autoloaderClasses'] );
519 }
520
521 foreach ( $info['defines'] as $name => $val ) {
522 if ( !defined( $name ) ) {
523 define( $name, $val );
524 } elseif ( constant( $name ) !== $val ) {
525 throw new UnexpectedValueException(
526 "$name cannot be re-defined with $val it has already been set with " . constant( $name )
527 );
528 }
529 }
530
531 if ( isset( $info['autoloaderPaths'] ) ) {
532 AutoLoader::loadFiles( $info['autoloaderPaths'] );
533 }
534
535 $this->loaded += $info['credits'];
536 if ( $info['attributes'] ) {
537 if ( !$this->attributes ) {
538 $this->attributes = $info['attributes'];
539 } else {
540 $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
541 }
542 }
543
544 // XXX: SettingsBuilder should really be a parameter to this method.
545 $settings = $this->getSettingsBuilder();
546
547 foreach ( $info['callbacks'] as $name => $cb ) {
548 if ( !is_callable( $cb ) ) {
549 if ( is_array( $cb ) ) {
550 $cb = '[ ' . implode( ', ', $cb ) . ' ]';
551 }
552 throw new UnexpectedValueException( "callback '$cb' is not callable" );
553 }
554 $cb( $info['credits'][$name], $settings );
555 }
556 }
557
566 public function isLoaded( $name, $constraint = '*' ) {
567 $isLoaded = isset( $this->loaded[$name] );
568 if ( $constraint === '*' || !$isLoaded ) {
569 return $isLoaded;
570 }
571 // if a specific constraint is requested, but no version is set, throw an exception
572 if ( !isset( $this->loaded[$name]['version'] ) ) {
573 $msg = "{$name} does not expose its version, but an extension or a skin"
574 . " requires: {$constraint}.";
575 throw new LogicException( $msg );
576 }
577
578 return Semver::satisfies( $this->loaded[$name]['version'], $constraint );
579 }
580
585 public function getAttribute( $name ) {
586 if ( isset( $this->testAttributes[$name] ) ) {
587 return $this->testAttributes[$name];
588 }
589
590 if ( in_array( $name, self::LAZY_LOADED_ATTRIBUTES, true ) ) {
591 return $this->getLazyLoadedAttribute( $name );
592 }
593
594 return $this->attributes[$name] ?? [];
595 }
596
603 protected function getLazyLoadedAttribute( $name ) {
604 if ( isset( $this->testAttributes[$name] ) ) {
605 return $this->testAttributes[$name];
606 }
607 if ( isset( $this->lazyAttributes[$name] ) ) {
608 return $this->lazyAttributes[$name];
609 }
610
611 // See if it's in the cache
612 $cache = $this->getCache();
613 $key = $this->makeCacheKey( $cache, 'lazy-attrib', $name );
614 $data = $cache->get( $key );
615 if ( $data !== false ) {
616 $this->lazyAttributes[$name] = $data;
617 return $data;
618 }
619
620 $paths = [];
621 foreach ( $this->loaded as $info ) {
622 // mtime (array value) doesn't matter here since
623 // we're skipping cache, so use a dummy time
624 $paths[$info['path']] = 1;
625 }
626
627 $result = $this->readFromQueue( $paths );
628 $data = $result['attributes'][$name] ?? [];
629 $this->saveToCache( $cache, $result );
630 $this->lazyAttributes[$name] = $data;
631
632 return $data;
633 }
634
643 public function setAttributeForTest( $name, array $value ) {
644 // @codeCoverageIgnoreStart
645 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
646 throw new LogicException( __METHOD__ . ' can only be used in tests' );
647 }
648 // @codeCoverageIgnoreEnd
649 if ( isset( $this->testAttributes[$name] ) ) {
650 throw new InvalidArgumentException( "The attribute '$name' has already been overridden" );
651 }
652 $this->testAttributes[$name] = $value;
653 return new ScopedCallback( function () use ( $name ) {
654 unset( $this->testAttributes[$name] );
655 } );
656 }
657
663 public function getAllThings() {
664 return $this->loaded;
665 }
666
674 protected static function processAutoLoader( $dir, array $files ) {
675 // Make paths absolute, relative to the JSON file
676 foreach ( $files as &$file ) {
677 $file = "$dir/$file";
678 }
679 return $files;
680 }
681
686 public function setSettingsBuilder( SettingsBuilder $settingsBuilder ) {
687 $this->settingsBuilder = $settingsBuilder;
688 }
689
690 private function getSettingsBuilder(): SettingsBuilder {
691 if ( $this->settingsBuilder === null ) {
692 $this->settingsBuilder = SettingsBuilder::getInstance();
693 }
694 return $this->settingsBuilder;
695 }
696}
const MW_VERSION
The running version of MediaWiki.
Definition Defines.php:36
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
Thrown when ExtensionRegistry cannot open the extension.json or skin.json file.
Check whether extensions and their dependencies meet certain version requirements.
$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.