MediaWiki  master
ExtensionRegistry.php
Go to the documentation of this file.
1 <?php
2 
3 use Composer\Semver\Semver;
6 use Wikimedia\ScopedCallback;
7 
16 
20  public const MEDIAWIKI_CORE = 'MediaWiki';
21 
26  public const MANIFEST_VERSION = 2;
27 
32  public const MANIFEST_VERSION_MW_VERSION = '>= 1.29.0';
33 
37  public const OLDEST_MANIFEST_VERSION = 1;
38 
42  private const CACHE_VERSION = 8;
43 
44  private const CACHE_EXPIRY = 60 * 60 * 24;
45 
51  public const MERGE_STRATEGY = '_merge_strategy';
52 
56  private const LAZY_LOADED_ATTRIBUTES = [
57  'TrackingCategories',
58  'QUnitTestModules',
59  'SkinLessImportPaths',
60  ];
61 
73  private $loaded = [];
74 
80  protected $queued = [];
81 
87  private $finished = false;
88 
95  protected $attributes = [];
96 
102  protected $testAttributes = [];
103 
109  protected $lazyAttributes = [];
110 
116  private $varyHash;
117 
123  protected $checkDev = false;
124 
130  protected $loadTestClassesAndNamespaces = false;
131 
135  private static $instance;
136 
140  private $cache = null;
141 
146  public static function getInstance() {
147  if ( self::$instance === null ) {
148  self::$instance = new self();
149  }
150 
151  return self::$instance;
152  }
153 
162  public function setCache( BagOStuff $cache ): void {
163  $this->cache = $cache;
164  }
165 
170  public function setCheckDevRequires( $check ) {
171  $this->checkDev = $check;
172  $this->invalidateProcessCache();
173  }
174 
182  public function setLoadTestClassesAndNamespaces( $load ) {
183  $this->loadTestClassesAndNamespaces = $load;
184  }
185 
189  public function queue( $path ) {
190  global $wgExtensionInfoMTime;
191 
192  $mtime = $wgExtensionInfoMTime;
193  if ( $mtime === false ) {
194  // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
195  $mtime = @filemtime( $path );
196  // @codeCoverageIgnoreStart
197  if ( $mtime === false ) {
198  $err = error_get_last();
199  throw new Exception( "Unable to open file $path: {$err['message']}" );
200  // @codeCoverageIgnoreEnd
201  }
202  }
203  $this->queued[$path] = $mtime;
204  $this->invalidateProcessCache();
205  }
206 
207  private function getCache(): BagOStuff {
208  if ( !$this->cache ) {
209  // Can't call MediaWikiServices here, as we must not cause services
210  // to be instantiated before extensions have loaded.
212  }
213 
214  return $this->cache;
215  }
216 
217  private function makeCacheKey( BagOStuff $cache, $component, ...$extra ) {
218  // Allow reusing cached ExtensionRegistry metadata between wikis (T274648)
219  return $cache->makeGlobalKey(
220  "registration-$component",
221  $this->getVaryHash(),
222  ...$extra
223  );
224  }
225 
231  private function getVaryHash() {
232  if ( $this->varyHash === null ) {
233  // We vary the cache on the current queue (what will be or already was loaded)
234  // plus various versions of stuff for VersionChecker
235  $vary = [
236  'registration' => self::CACHE_VERSION,
237  'mediawiki' => MW_VERSION,
238  'abilities' => $this->getAbilities(),
239  'checkDev' => $this->checkDev,
240  'queue' => $this->queued,
241  ];
242  $this->varyHash = md5( json_encode( $vary ) );
243  }
244  return $this->varyHash;
245  }
246 
250  private function invalidateProcessCache() {
251  $this->varyHash = null;
252  $this->lazyAttributes = [];
253  }
254 
259  public function loadFromQueue() {
260  if ( !$this->queued ) {
261  return;
262  }
263 
264  if ( $this->finished ) {
265  throw new MWException(
266  "The following paths tried to load late: "
267  . implode( ', ', array_keys( $this->queued ) )
268  );
269  }
270 
271  $cache = $this->getCache();
272  // See if this queue is in APC
273  $key = $this->makeCacheKey( $cache, 'main' );
274  $data = $cache->get( $key );
275  if ( !$data ) {
276  $data = $this->readFromQueue( $this->queued );
277  $this->saveToCache( $cache, $data );
278  }
279  $this->exportExtractedData( $data );
280  }
281 
288  protected function saveToCache( BagOStuff $cache, array $data ) {
289  global $wgDevelopmentWarnings;
290  if ( $data['warnings'] && $wgDevelopmentWarnings ) {
291  // If warnings were shown, don't cache it
292  return;
293  }
294  $lazy = [];
295  // Cache lazy-loaded attributes separately
296  foreach ( self::LAZY_LOADED_ATTRIBUTES as $attrib ) {
297  if ( isset( $data['attributes'][$attrib] ) ) {
298  $lazy[$attrib] = $data['attributes'][$attrib];
299  unset( $data['attributes'][$attrib] );
300  }
301  }
302  $mainKey = $this->makeCacheKey( $cache, 'main' );
303  $cache->set( $mainKey, $data, self::CACHE_EXPIRY );
304  foreach ( $lazy as $attrib => $value ) {
305  $cache->set(
306  $this->makeCacheKey( $cache, 'lazy-attrib', $attrib ),
307  $value,
308  self::CACHE_EXPIRY
309  );
310  }
311  }
312 
319  public function getQueue() {
320  return $this->queued;
321  }
322 
327  public function clearQueue() {
328  $this->queued = [];
329  $this->invalidateProcessCache();
330  }
331 
337  public function finish() {
338  $this->finished = true;
339  }
340 
345  private function getAbilities() {
346  return [
347  'shell' => !Shell::isDisabled(),
348  ];
349  }
350 
356  private function buildVersionChecker() {
357  // array to optionally specify more verbose error messages for
358  // missing abilities
359  $abilityErrors = [
360  'shell' => ( new ShellDisabledError() )->getMessage(),
361  ];
362 
363  return new VersionChecker(
364  MW_VERSION,
365  PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION,
366  get_loaded_extensions(),
367  $this->getAbilities(),
368  $abilityErrors
369  );
370  }
371 
382  public function readFromQueue( array $queue ) {
383  $processor = new ExtensionProcessor();
384  $versionChecker = $this->buildVersionChecker();
385  $extDependencies = [];
386  $warnings = false;
387  foreach ( $queue as $path => $mtime ) {
388  $json = file_get_contents( $path );
389  if ( $json === false ) {
390  throw new Exception( "Unable to read $path, does it exist?" );
391  }
392  $info = json_decode( $json, /* $assoc = */ true );
393  if ( !is_array( $info ) ) {
394  throw new Exception( "$path is not a valid JSON file." );
395  }
396 
397  if ( !isset( $info['manifest_version'] ) ) {
399  "{$info['name']}'s extension.json or skin.json does not have manifest_version, " .
400  'this is deprecated since MediaWiki 1.29',
401  '1.29', false, false
402  );
403  $warnings = true;
404  // For backwards-compatibility, assume a version of 1
405  $info['manifest_version'] = 1;
406  }
407  $version = $info['manifest_version'];
408  if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) {
409  throw new Exception( "$path: unsupported manifest_version: {$version}" );
410  }
411 
412  // get all requirements/dependencies for this extension
413  $requires = $processor->getRequirements( $info, $this->checkDev );
414 
415  // validate the information needed and add the requirements
416  if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) {
417  $extDependencies[$info['name']] = $requires;
418  }
419 
420  // Compatible, read and extract info
421  $processor->extractInfo( $path, $info, $version );
422  }
423  $data = $processor->getExtractedInfo( $this->loadTestClassesAndNamespaces );
424  $data['warnings'] = $warnings;
425 
426  // check for incompatible extensions
427  $incompatible = $versionChecker
428  ->setLoadedExtensionsAndSkins( $data['credits'] )
429  ->checkArray( $extDependencies );
430 
431  if ( $incompatible ) {
432  throw new ExtensionDependencyError( $incompatible );
433  }
434 
435  return $data;
436  }
437 
438  protected function exportExtractedData( array $info ) {
439  foreach ( $info['globals'] as $key => $val ) {
440  // If a merge strategy is set, read it and remove it from the value
441  // so it doesn't accidentally end up getting set.
442  if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) {
443  $mergeStrategy = $val[self::MERGE_STRATEGY];
444  unset( $val[self::MERGE_STRATEGY] );
445  } else {
446  $mergeStrategy = 'array_merge';
447  }
448 
449  if ( $mergeStrategy === 'provide_default' ) {
450  if ( !array_key_exists( $key, $GLOBALS ) ) {
451  $GLOBALS[$key] = $val;
452  }
453  continue;
454  }
455 
456  // Optimistic: If the global is not set, or is an empty array, replace it entirely.
457  // Will be O(1) performance.
458  if ( !array_key_exists( $key, $GLOBALS ) || ( is_array( $GLOBALS[$key] ) && !$GLOBALS[$key] ) ) {
459  $GLOBALS[$key] = $val;
460  continue;
461  }
462 
463  if ( !is_array( $GLOBALS[$key] ) || !is_array( $val ) ) {
464  // config setting that has already been overridden, don't set it
465  continue;
466  }
467 
468  switch ( $mergeStrategy ) {
469  case 'array_merge_recursive':
470  $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
471  break;
472  case 'array_replace_recursive':
473  $GLOBALS[$key] = array_replace_recursive( $val, $GLOBALS[$key] );
474  break;
475  case 'array_plus_2d':
476  $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val );
477  break;
478  case 'array_plus':
479  $GLOBALS[$key] += $val;
480  break;
481  case 'array_merge':
482  $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] );
483  break;
484  default:
485  throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" );
486  }
487  }
488 
489  if ( isset( $info['autoloaderNS'] ) ) {
490  AutoLoader::registerNamespaces( $info['autoloaderNS'] );
491  }
492 
493  if ( isset( $info['autoloaderClasses'] ) ) {
494  AutoLoader::registerClasses( $info['autoloaderClasses'] );
495  }
496 
497  foreach ( $info['defines'] as $name => $val ) {
498  if ( !defined( $name ) ) {
499  define( $name, $val );
500  } elseif ( constant( $name ) !== $val ) {
501  throw new UnexpectedValueException(
502  "$name cannot be re-defined with $val it has already been set with " . constant( $name )
503  );
504  }
505  }
506 
507  if ( isset( $info['autoloaderPaths'] ) ) {
508  AutoLoader::loadFiles( $info['autoloaderPaths'] );
509  }
510 
511  $this->loaded += $info['credits'];
512  if ( $info['attributes'] ) {
513  if ( !$this->attributes ) {
514  $this->attributes = $info['attributes'];
515  } else {
516  $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
517  }
518  }
519 
520  foreach ( $info['callbacks'] as $name => $cb ) {
521  if ( !is_callable( $cb ) ) {
522  if ( is_array( $cb ) ) {
523  $cb = '[ ' . implode( ', ', $cb ) . ' ]';
524  }
525  throw new UnexpectedValueException( "callback '$cb' is not callable" );
526  }
527  $cb( $info['credits'][$name] );
528  }
529  }
530 
539  public function isLoaded( $name, $constraint = '*' ) {
540  $isLoaded = isset( $this->loaded[$name] );
541  if ( $constraint === '*' || !$isLoaded ) {
542  return $isLoaded;
543  }
544  // if a specific constraint is requested, but no version is set, throw an exception
545  if ( !isset( $this->loaded[$name]['version'] ) ) {
546  $msg = "{$name} does not expose its version, but an extension or a skin"
547  . " requires: {$constraint}.";
548  throw new LogicException( $msg );
549  }
550 
551  return Semver::satisfies( $this->loaded[$name]['version'], $constraint );
552  }
553 
558  public function getAttribute( $name ) {
559  if ( isset( $this->testAttributes[$name] ) ) {
560  return $this->testAttributes[$name];
561  }
562 
563  if ( in_array( $name, self::LAZY_LOADED_ATTRIBUTES, true ) ) {
564  return $this->getLazyLoadedAttribute( $name );
565  }
566 
567  return $this->attributes[$name] ?? [];
568  }
569 
576  protected function getLazyLoadedAttribute( $name ) {
577  if ( isset( $this->testAttributes[$name] ) ) {
578  return $this->testAttributes[$name];
579  }
580  if ( isset( $this->lazyAttributes[$name] ) ) {
581  return $this->lazyAttributes[$name];
582  }
583 
584  // See if it's in the cache
585  $cache = $this->getCache();
586  $key = $this->makeCacheKey( $cache, 'lazy-attrib', $name );
587  $data = $cache->get( $key );
588  if ( $data !== false ) {
589  $this->lazyAttributes[$name] = $data;
590  return $data;
591  }
592 
593  $paths = [];
594  foreach ( $this->loaded as $info ) {
595  // mtime (array value) doesn't matter here since
596  // we're skipping cache, so use a dummy time
597  $paths[$info['path']] = 1;
598  }
599 
600  $result = $this->readFromQueue( $paths );
601  $data = $result['attributes'][$name] ?? [];
602  $this->saveToCache( $cache, $result );
603  $this->lazyAttributes[$name] = $data;
604 
605  return $data;
606  }
607 
616  public function setAttributeForTest( $name, array $value ) {
617  // @codeCoverageIgnoreStart
618  if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
619  throw new RuntimeException( __METHOD__ . ' can only be used in tests' );
620  }
621  // @codeCoverageIgnoreEnd
622  if ( isset( $this->testAttributes[$name] ) ) {
623  throw new Exception( "The attribute '$name' has already been overridden" );
624  }
625  $this->testAttributes[$name] = $value;
626  return new ScopedCallback( function () use ( $name ) {
627  unset( $this->testAttributes[$name] );
628  } );
629  }
630 
636  public function getAllThings() {
637  return $this->loaded;
638  }
639 
647  protected static function processAutoLoader( $dir, array $files ) {
648  // Make paths absolute, relative to the JSON file
649  foreach ( $files as &$file ) {
650  $file = "$dir/$file";
651  }
652  return $files;
653  }
654 }
const MW_VERSION
The running version of MediaWiki.
Definition: Defines.php:36
wfDeprecatedMsg( $msg, $version=false, $component=false, $callerOffset=2)
Log a deprecation warning with arbitrary message text.
wfArrayPlus2d(array $baseArray, array $newValues)
Merges two (possibly) 2 dimensional arrays into the target array ($baseArray).
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
Definition: WebStart.php:82
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:87
Copyright (C) 2018 Kunal Mehta legoktm@debian.org
Utility class for loading extension manifests and aggregating their contents.
The Registry loads JSON files, and uses a Processor to extract information from them.
setLoadTestClassesAndNamespaces( $load)
Controls if classes and namespaces defined under the keys TestAutoloadClasses and TestAutoloadNamespa...
static processAutoLoader( $dir, array $files)
Fully expand autoloader paths.
const LAZY_LOADED_ATTRIBUTES
Attributes that should be lazy-loaded.
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.
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.
getAbilities()
Get the list of abilities and their values.
const CACHE_VERSION
Bump whenever the registration cache needs resetting.
array $testAttributes
Attributes for testing.
makeCacheKey(BagOStuff $cache, $component,... $extra)
clearQueue()
Clear the current load queue.
invalidateProcessCache()
Invalidate the cache of the vary hash and the lazy options.
array $lazyAttributes
Lazy-loaded attributes.
array[] $loaded
Array of loaded things, keyed by name, values are credits information.
setCache(BagOStuff $cache)
Set the cache to use for extension info.
static ExtensionRegistry $instance
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)
bool $finished
Whether we are done loading things.
getAllThings()
Get credits information about all installed extensions and skins.
getVaryHash()
Get the cache varying hash.
finish()
After this is called, no more extensions can be loaded.
buildVersionChecker()
Queries information about the software environment and constructs an appropriate version checker.
array $attributes
Items in the JSON file that aren't being set as globals.
string null $varyHash
The hash of cache-varying options, lazy-initialised.
bool $checkDev
Whether to check dev-requires.
MediaWiki exception.
Definition: MWException.php:29
Executes shell commands.
Definition: Shell.php:46
static makeLocalServerCache()
Create a new BagOStuff instance for local-server caching.
Provides functions to check a set of extensions with dependencies against a set of loaded extensions ...
$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.
$cache
Definition: mcc.php:33
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42