MediaWiki  master
ExtensionRegistry.php
Go to the documentation of this file.
1 <?php
2 
3 use Composer\Semver\Semver;
7 use 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 
138  protected $loadTestClassesAndNamespaces = false;
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 ) {
228  global $wgExtensionInfoMTime;
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.
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 
297  public function loadFromQueue() {
298  if ( !$this->queued ) {
299  return;
300  }
301 
302  if ( $this->finished ) {
303  throw new MWException(
304  "The following paths tried to load late: "
305  . implode( ', ', array_keys( $this->queued ) )
306  );
307  }
308 
309  $cache = $this->getCache();
310  // See if this queue is in APC
311  $key = $this->makeCacheKey( $cache, 'main' );
312  $data = $cache->get( $key );
313  if ( !$data ) {
314  $data = $this->readFromQueue( $this->queued );
315  $this->saveToCache( $cache, $data );
316  }
317  $this->exportExtractedData( $data );
318  }
319 
326  protected function saveToCache( BagOStuff $cache, array $data ) {
327  global $wgDevelopmentWarnings;
328  if ( $data['warnings'] && $wgDevelopmentWarnings ) {
329  // If warnings were shown, don't cache it
330  return;
331  }
332  $lazy = [];
333  // Cache lazy-loaded attributes separately
334  foreach ( self::LAZY_LOADED_ATTRIBUTES as $attrib ) {
335  if ( isset( $data['attributes'][$attrib] ) ) {
336  $lazy[$attrib] = $data['attributes'][$attrib];
337  unset( $data['attributes'][$attrib] );
338  }
339  }
340  $mainKey = $this->makeCacheKey( $cache, 'main' );
341  $cache->set( $mainKey, $data, self::CACHE_EXPIRY );
342  foreach ( $lazy as $attrib => $value ) {
343  $cache->set(
344  $this->makeCacheKey( $cache, 'lazy-attrib', $attrib ),
345  $value,
346  self::CACHE_EXPIRY
347  );
348  }
349  }
350 
357  public function getQueue() {
358  return $this->queued;
359  }
360 
365  public function clearQueue() {
366  $this->queued = [];
367  $this->invalidateProcessCache();
368  }
369 
375  public function finish() {
376  $this->finished = true;
377  }
378 
383  private function getAbilities() {
384  return [
385  'shell' => !Shell::isDisabled(),
386  ];
387  }
388 
394  private function buildVersionChecker() {
395  // array to optionally specify more verbose error messages for
396  // missing abilities
397  $abilityErrors = [
398  'shell' => ( new ShellDisabledError() )->getMessage(),
399  ];
400 
401  return new VersionChecker(
402  MW_VERSION,
403  PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION,
404  get_loaded_extensions(),
405  $this->getAbilities(),
406  $abilityErrors
407  );
408  }
409 
420  public function readFromQueue( array $queue ) {
421  $processor = new ExtensionProcessor();
422  $versionChecker = $this->buildVersionChecker();
423  $extDependencies = [];
424  $warnings = false;
425  foreach ( $queue as $path => $mtime ) {
426  $json = file_get_contents( $path );
427  if ( $json === false ) {
428  throw new Exception( "Unable to read $path, does it exist?" );
429  }
430  $info = json_decode( $json, /* $assoc = */ true );
431  if ( !is_array( $info ) ) {
432  throw new Exception( "$path is not a valid JSON file." );
433  }
434 
435  $version = $info['manifest_version'];
436  if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) {
437  throw new Exception( "$path: unsupported manifest_version: {$version}" );
438  }
439 
440  // get all requirements/dependencies for this extension
441  $requires = $processor->getRequirements( $info, $this->checkDev );
442 
443  // validate the information needed and add the requirements
444  if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) {
445  $extDependencies[$info['name']] = $requires;
446  }
447 
448  // Compatible, read and extract info
449  $processor->extractInfo( $path, $info, $version );
450  }
451  $data = $processor->getExtractedInfo( $this->loadTestClassesAndNamespaces );
452  $data['warnings'] = $warnings;
453 
454  // check for incompatible extensions
455  $incompatible = $versionChecker
456  ->setLoadedExtensionsAndSkins( $data['credits'] )
457  ->checkArray( $extDependencies );
458 
459  if ( $incompatible ) {
460  throw new ExtensionDependencyError( $incompatible );
461  }
462 
463  return $data;
464  }
465 
466  protected function exportExtractedData( array $info ) {
467  foreach ( $info['globals'] as $key => $val ) {
468  // If a merge strategy is set, read it and remove it from the value
469  // so it doesn't accidentally end up getting set.
470  if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) {
471  $mergeStrategy = $val[self::MERGE_STRATEGY];
472  unset( $val[self::MERGE_STRATEGY] );
473  } else {
474  $mergeStrategy = 'array_merge';
475  }
476 
477  if ( $mergeStrategy === 'provide_default' ) {
478  if ( !array_key_exists( $key, $GLOBALS ) ) {
479  $GLOBALS[$key] = $val;
480  }
481  continue;
482  }
483 
484  // Optimistic: If the global is not set, or is an empty array, replace it entirely.
485  // Will be O(1) performance.
486  if ( !array_key_exists( $key, $GLOBALS ) || ( is_array( $GLOBALS[$key] ) && !$GLOBALS[$key] ) ) {
487  $GLOBALS[$key] = $val;
488  continue;
489  }
490 
491  if ( !is_array( $GLOBALS[$key] ) || !is_array( $val ) ) {
492  // config setting that has already been overridden, don't set it
493  continue;
494  }
495 
496  switch ( $mergeStrategy ) {
497  case 'array_merge_recursive':
498  $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
499  break;
500  case 'array_replace_recursive':
501  $GLOBALS[$key] = array_replace_recursive( $val, $GLOBALS[$key] );
502  break;
503  case 'array_plus_2d':
504  $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val );
505  break;
506  case 'array_plus':
507  $GLOBALS[$key] += $val;
508  break;
509  case 'array_merge':
510  $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] );
511  break;
512  default:
513  throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" );
514  }
515  }
516 
517  if ( isset( $info['autoloaderNS'] ) ) {
518  AutoLoader::registerNamespaces( $info['autoloaderNS'] );
519  }
520 
521  if ( isset( $info['autoloaderClasses'] ) ) {
522  AutoLoader::registerClasses( $info['autoloaderClasses'] );
523  }
524 
525  foreach ( $info['defines'] as $name => $val ) {
526  if ( !defined( $name ) ) {
527  define( $name, $val );
528  } elseif ( constant( $name ) !== $val ) {
529  throw new UnexpectedValueException(
530  "$name cannot be re-defined with $val it has already been set with " . constant( $name )
531  );
532  }
533  }
534 
535  if ( isset( $info['autoloaderPaths'] ) ) {
536  AutoLoader::loadFiles( $info['autoloaderPaths'] );
537  }
538 
539  $this->loaded += $info['credits'];
540  if ( $info['attributes'] ) {
541  if ( !$this->attributes ) {
542  $this->attributes = $info['attributes'];
543  } else {
544  $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
545  }
546  }
547 
548  // XXX: SettingsBuilder should really be a parameter to this method.
549  $settings = $this->getSettingsBuilder();
550 
551  foreach ( $info['callbacks'] as $name => $cb ) {
552  if ( !is_callable( $cb ) ) {
553  if ( is_array( $cb ) ) {
554  $cb = '[ ' . implode( ', ', $cb ) . ' ]';
555  }
556  throw new UnexpectedValueException( "callback '$cb' is not callable" );
557  }
558  $cb( $info['credits'][$name], $settings );
559  }
560  }
561 
570  public function isLoaded( $name, $constraint = '*' ) {
571  $isLoaded = isset( $this->loaded[$name] );
572  if ( $constraint === '*' || !$isLoaded ) {
573  return $isLoaded;
574  }
575  // if a specific constraint is requested, but no version is set, throw an exception
576  if ( !isset( $this->loaded[$name]['version'] ) ) {
577  $msg = "{$name} does not expose its version, but an extension or a skin"
578  . " requires: {$constraint}.";
579  throw new LogicException( $msg );
580  }
581 
582  return Semver::satisfies( $this->loaded[$name]['version'], $constraint );
583  }
584 
589  public function getAttribute( $name ) {
590  if ( isset( $this->testAttributes[$name] ) ) {
591  return $this->testAttributes[$name];
592  }
593 
594  if ( in_array( $name, self::LAZY_LOADED_ATTRIBUTES, true ) ) {
595  return $this->getLazyLoadedAttribute( $name );
596  }
597 
598  return $this->attributes[$name] ?? [];
599  }
600 
607  protected function getLazyLoadedAttribute( $name ) {
608  if ( isset( $this->testAttributes[$name] ) ) {
609  return $this->testAttributes[$name];
610  }
611  if ( isset( $this->lazyAttributes[$name] ) ) {
612  return $this->lazyAttributes[$name];
613  }
614 
615  // See if it's in the cache
616  $cache = $this->getCache();
617  $key = $this->makeCacheKey( $cache, 'lazy-attrib', $name );
618  $data = $cache->get( $key );
619  if ( $data !== false ) {
620  $this->lazyAttributes[$name] = $data;
621  return $data;
622  }
623 
624  $paths = [];
625  foreach ( $this->loaded as $info ) {
626  // mtime (array value) doesn't matter here since
627  // we're skipping cache, so use a dummy time
628  $paths[$info['path']] = 1;
629  }
630 
631  $result = $this->readFromQueue( $paths );
632  $data = $result['attributes'][$name] ?? [];
633  $this->saveToCache( $cache, $result );
634  $this->lazyAttributes[$name] = $data;
635 
636  return $data;
637  }
638 
647  public function setAttributeForTest( $name, array $value ) {
648  // @codeCoverageIgnoreStart
649  if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
650  throw new LogicException( __METHOD__ . ' can only be used in tests' );
651  }
652  // @codeCoverageIgnoreEnd
653  if ( isset( $this->testAttributes[$name] ) ) {
654  throw new Exception( "The attribute '$name' has already been overridden" );
655  }
656  $this->testAttributes[$name] = $value;
657  return new ScopedCallback( function () use ( $name ) {
658  unset( $this->testAttributes[$name] );
659  } );
660  }
661 
667  public function getAllThings() {
668  return $this->loaded;
669  }
670 
678  protected static function processAutoLoader( $dir, array $files ) {
679  // Make paths absolute, relative to the JSON file
680  foreach ( $files as &$file ) {
681  $file = "$dir/$file";
682  }
683  return $files;
684  }
685 
690  public function setSettingsBuilder( SettingsBuilder $settingsBuilder ) {
691  $this->settingsBuilder = $settingsBuilder;
692  }
693 
694  private function getSettingsBuilder(): SettingsBuilder {
695  if ( $this->settingsBuilder === null ) {
696  $this->settingsBuilder = SettingsBuilder::getInstance();
697  }
698  return $this->settingsBuilder;
699  }
700 }
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:88
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.
Definition: BagOStuff.php:516
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.
MediaWiki exception.
Definition: MWException.php:33
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.
static makeLocalServerCache()
Create a new BagOStuff instance for local-server caching.
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.
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42