MediaWiki  master
ExtensionRegistry.php
Go to the documentation of this file.
1 <?php
2 
3 use Composer\Semver\Semver;
7 use Wikimedia\ScopedCallback;
8 
17 
21  public const MEDIAWIKI_CORE = 'MediaWiki';
22 
27  public const MANIFEST_VERSION = 2;
28 
33  public const MANIFEST_VERSION_MW_VERSION = '>= 1.29.0';
34 
38  public const OLDEST_MANIFEST_VERSION = 1;
39 
43  private const CACHE_VERSION = 8;
44 
45  private const CACHE_EXPIRY = 60 * 60 * 24;
46 
52  public const MERGE_STRATEGY = '_merge_strategy';
53 
57  private const LAZY_LOADED_ATTRIBUTES = [
58  'TrackingCategories',
59  'QUnitTestModules',
60  'SkinLessImportPaths',
61  ];
62 
74  private $loaded = [];
75 
81  protected $queued = [];
82 
88  private $finished = false;
89 
96  protected $attributes = [];
97 
103  protected $testAttributes = [];
104 
110  protected $lazyAttributes = [];
111 
117  private $varyHash;
118 
124  protected $checkDev = false;
125 
131  protected $loadTestClassesAndNamespaces = false;
132 
136  private static $instance;
137 
141  private $cache = null;
142 
146  private ?SettingsBuilder $settingsBuilder = null;
147 
152  public static function getInstance() {
153  if ( self::$instance === null ) {
154  self::$instance = new self();
155  }
156 
157  return self::$instance;
158  }
159 
168  public function setCache( BagOStuff $cache ): void {
169  $this->cache = $cache;
170  }
171 
176  public function setCheckDevRequires( $check ) {
177  $this->checkDev = $check;
178  $this->invalidateProcessCache();
179  }
180 
188  public function setLoadTestClassesAndNamespaces( $load ) {
189  $this->loadTestClassesAndNamespaces = $load;
190  }
191 
195  public function queue( $path ) {
196  global $wgExtensionInfoMTime;
197 
198  $mtime = $wgExtensionInfoMTime;
199  if ( $mtime === false ) {
200  // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
201  $mtime = @filemtime( $path );
202  // @codeCoverageIgnoreStart
203  if ( $mtime === false ) {
204  $err = error_get_last();
205  throw new MissingExtensionException( $path, $err['message'] );
206  // @codeCoverageIgnoreEnd
207  }
208  }
209  $this->queued[$path] = $mtime;
210  $this->invalidateProcessCache();
211  }
212 
213  private function getCache(): BagOStuff {
214  if ( !$this->cache ) {
215  // Can't call MediaWikiServices here, as we must not cause services
216  // to be instantiated before extensions have loaded.
218  }
219 
220  return $this->cache;
221  }
222 
223  private function makeCacheKey( BagOStuff $cache, $component, ...$extra ) {
224  // Allow reusing cached ExtensionRegistry metadata between wikis (T274648)
225  return $cache->makeGlobalKey(
226  "registration-$component",
227  $this->getVaryHash(),
228  ...$extra
229  );
230  }
231 
237  private function getVaryHash() {
238  if ( $this->varyHash === null ) {
239  // We vary the cache on the current queue (what will be or already was loaded)
240  // plus various versions of stuff for VersionChecker
241  $vary = [
242  'registration' => self::CACHE_VERSION,
243  'mediawiki' => MW_VERSION,
244  'abilities' => $this->getAbilities(),
245  'checkDev' => $this->checkDev,
246  'queue' => $this->queued,
247  ];
248  $this->varyHash = md5( json_encode( $vary ) );
249  }
250  return $this->varyHash;
251  }
252 
256  private function invalidateProcessCache() {
257  $this->varyHash = null;
258  $this->lazyAttributes = [];
259  }
260 
265  public function loadFromQueue() {
266  if ( !$this->queued ) {
267  return;
268  }
269 
270  if ( $this->finished ) {
271  throw new MWException(
272  "The following paths tried to load late: "
273  . implode( ', ', array_keys( $this->queued ) )
274  );
275  }
276 
277  $cache = $this->getCache();
278  // See if this queue is in APC
279  $key = $this->makeCacheKey( $cache, 'main' );
280  $data = $cache->get( $key );
281  if ( !$data ) {
282  $data = $this->readFromQueue( $this->queued );
283  $this->saveToCache( $cache, $data );
284  }
285  $this->exportExtractedData( $data );
286  }
287 
294  protected function saveToCache( BagOStuff $cache, array $data ) {
295  global $wgDevelopmentWarnings;
296  if ( $data['warnings'] && $wgDevelopmentWarnings ) {
297  // If warnings were shown, don't cache it
298  return;
299  }
300  $lazy = [];
301  // Cache lazy-loaded attributes separately
302  foreach ( self::LAZY_LOADED_ATTRIBUTES as $attrib ) {
303  if ( isset( $data['attributes'][$attrib] ) ) {
304  $lazy[$attrib] = $data['attributes'][$attrib];
305  unset( $data['attributes'][$attrib] );
306  }
307  }
308  $mainKey = $this->makeCacheKey( $cache, 'main' );
309  $cache->set( $mainKey, $data, self::CACHE_EXPIRY );
310  foreach ( $lazy as $attrib => $value ) {
311  $cache->set(
312  $this->makeCacheKey( $cache, 'lazy-attrib', $attrib ),
313  $value,
314  self::CACHE_EXPIRY
315  );
316  }
317  }
318 
325  public function getQueue() {
326  return $this->queued;
327  }
328 
333  public function clearQueue() {
334  $this->queued = [];
335  $this->invalidateProcessCache();
336  }
337 
343  public function finish() {
344  $this->finished = true;
345  }
346 
351  private function getAbilities() {
352  return [
353  'shell' => !Shell::isDisabled(),
354  ];
355  }
356 
362  private function buildVersionChecker() {
363  // array to optionally specify more verbose error messages for
364  // missing abilities
365  $abilityErrors = [
366  'shell' => ( new ShellDisabledError() )->getMessage(),
367  ];
368 
369  return new VersionChecker(
370  MW_VERSION,
371  PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION,
372  get_loaded_extensions(),
373  $this->getAbilities(),
374  $abilityErrors
375  );
376  }
377 
388  public function readFromQueue( array $queue ) {
389  $processor = new ExtensionProcessor();
390  $versionChecker = $this->buildVersionChecker();
391  $extDependencies = [];
392  $warnings = false;
393  foreach ( $queue as $path => $mtime ) {
394  $json = file_get_contents( $path );
395  if ( $json === false ) {
396  throw new Exception( "Unable to read $path, does it exist?" );
397  }
398  $info = json_decode( $json, /* $assoc = */ true );
399  if ( !is_array( $info ) ) {
400  throw new Exception( "$path is not a valid JSON file." );
401  }
402 
403  if ( !isset( $info['manifest_version'] ) ) {
405  "{$info['name']}'s extension.json or skin.json does not have manifest_version, " .
406  'this is deprecated since MediaWiki 1.29',
407  '1.29', false, false
408  );
409  $warnings = true;
410  // For backwards-compatibility, assume a version of 1
411  $info['manifest_version'] = 1;
412  }
413  $version = $info['manifest_version'];
414  if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) {
415  throw new Exception( "$path: unsupported manifest_version: {$version}" );
416  }
417 
418  // get all requirements/dependencies for this extension
419  $requires = $processor->getRequirements( $info, $this->checkDev );
420 
421  // validate the information needed and add the requirements
422  if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) {
423  $extDependencies[$info['name']] = $requires;
424  }
425 
426  // Compatible, read and extract info
427  $processor->extractInfo( $path, $info, $version );
428  }
429  $data = $processor->getExtractedInfo( $this->loadTestClassesAndNamespaces );
430  $data['warnings'] = $warnings;
431 
432  // check for incompatible extensions
433  $incompatible = $versionChecker
434  ->setLoadedExtensionsAndSkins( $data['credits'] )
435  ->checkArray( $extDependencies );
436 
437  if ( $incompatible ) {
438  throw new ExtensionDependencyError( $incompatible );
439  }
440 
441  return $data;
442  }
443 
444  protected function exportExtractedData( array $info ) {
445  foreach ( $info['globals'] as $key => $val ) {
446  // If a merge strategy is set, read it and remove it from the value
447  // so it doesn't accidentally end up getting set.
448  if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) {
449  $mergeStrategy = $val[self::MERGE_STRATEGY];
450  unset( $val[self::MERGE_STRATEGY] );
451  } else {
452  $mergeStrategy = 'array_merge';
453  }
454 
455  if ( $mergeStrategy === 'provide_default' ) {
456  if ( !array_key_exists( $key, $GLOBALS ) ) {
457  $GLOBALS[$key] = $val;
458  }
459  continue;
460  }
461 
462  // Optimistic: If the global is not set, or is an empty array, replace it entirely.
463  // Will be O(1) performance.
464  if ( !array_key_exists( $key, $GLOBALS ) || ( is_array( $GLOBALS[$key] ) && !$GLOBALS[$key] ) ) {
465  $GLOBALS[$key] = $val;
466  continue;
467  }
468 
469  if ( !is_array( $GLOBALS[$key] ) || !is_array( $val ) ) {
470  // config setting that has already been overridden, don't set it
471  continue;
472  }
473 
474  switch ( $mergeStrategy ) {
475  case 'array_merge_recursive':
476  $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
477  break;
478  case 'array_replace_recursive':
479  $GLOBALS[$key] = array_replace_recursive( $val, $GLOBALS[$key] );
480  break;
481  case 'array_plus_2d':
482  $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val );
483  break;
484  case 'array_plus':
485  $GLOBALS[$key] += $val;
486  break;
487  case 'array_merge':
488  $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] );
489  break;
490  default:
491  throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" );
492  }
493  }
494 
495  if ( isset( $info['autoloaderNS'] ) ) {
496  AutoLoader::registerNamespaces( $info['autoloaderNS'] );
497  }
498 
499  if ( isset( $info['autoloaderClasses'] ) ) {
500  AutoLoader::registerClasses( $info['autoloaderClasses'] );
501  }
502 
503  foreach ( $info['defines'] as $name => $val ) {
504  if ( !defined( $name ) ) {
505  define( $name, $val );
506  } elseif ( constant( $name ) !== $val ) {
507  throw new UnexpectedValueException(
508  "$name cannot be re-defined with $val it has already been set with " . constant( $name )
509  );
510  }
511  }
512 
513  if ( isset( $info['autoloaderPaths'] ) ) {
514  AutoLoader::loadFiles( $info['autoloaderPaths'] );
515  }
516 
517  $this->loaded += $info['credits'];
518  if ( $info['attributes'] ) {
519  if ( !$this->attributes ) {
520  $this->attributes = $info['attributes'];
521  } else {
522  $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
523  }
524  }
525 
526  // XXX: SettingsBuilder should really be a parameter to this method.
527  $settings = $this->getSettingsBuilder();
528 
529  foreach ( $info['callbacks'] as $name => $cb ) {
530  if ( !is_callable( $cb ) ) {
531  if ( is_array( $cb ) ) {
532  $cb = '[ ' . implode( ', ', $cb ) . ' ]';
533  }
534  throw new UnexpectedValueException( "callback '$cb' is not callable" );
535  }
536  $cb( $info['credits'][$name], $settings );
537  }
538  }
539 
548  public function isLoaded( $name, $constraint = '*' ) {
549  $isLoaded = isset( $this->loaded[$name] );
550  if ( $constraint === '*' || !$isLoaded ) {
551  return $isLoaded;
552  }
553  // if a specific constraint is requested, but no version is set, throw an exception
554  if ( !isset( $this->loaded[$name]['version'] ) ) {
555  $msg = "{$name} does not expose its version, but an extension or a skin"
556  . " requires: {$constraint}.";
557  throw new LogicException( $msg );
558  }
559 
560  return Semver::satisfies( $this->loaded[$name]['version'], $constraint );
561  }
562 
567  public function getAttribute( $name ) {
568  if ( isset( $this->testAttributes[$name] ) ) {
569  return $this->testAttributes[$name];
570  }
571 
572  if ( in_array( $name, self::LAZY_LOADED_ATTRIBUTES, true ) ) {
573  return $this->getLazyLoadedAttribute( $name );
574  }
575 
576  return $this->attributes[$name] ?? [];
577  }
578 
585  protected function getLazyLoadedAttribute( $name ) {
586  if ( isset( $this->testAttributes[$name] ) ) {
587  return $this->testAttributes[$name];
588  }
589  if ( isset( $this->lazyAttributes[$name] ) ) {
590  return $this->lazyAttributes[$name];
591  }
592 
593  // See if it's in the cache
594  $cache = $this->getCache();
595  $key = $this->makeCacheKey( $cache, 'lazy-attrib', $name );
596  $data = $cache->get( $key );
597  if ( $data !== false ) {
598  $this->lazyAttributes[$name] = $data;
599  return $data;
600  }
601 
602  $paths = [];
603  foreach ( $this->loaded as $info ) {
604  // mtime (array value) doesn't matter here since
605  // we're skipping cache, so use a dummy time
606  $paths[$info['path']] = 1;
607  }
608 
609  $result = $this->readFromQueue( $paths );
610  $data = $result['attributes'][$name] ?? [];
611  $this->saveToCache( $cache, $result );
612  $this->lazyAttributes[$name] = $data;
613 
614  return $data;
615  }
616 
625  public function setAttributeForTest( $name, array $value ) {
626  // @codeCoverageIgnoreStart
627  if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
628  throw new RuntimeException( __METHOD__ . ' can only be used in tests' );
629  }
630  // @codeCoverageIgnoreEnd
631  if ( isset( $this->testAttributes[$name] ) ) {
632  throw new Exception( "The attribute '$name' has already been overridden" );
633  }
634  $this->testAttributes[$name] = $value;
635  return new ScopedCallback( function () use ( $name ) {
636  unset( $this->testAttributes[$name] );
637  } );
638  }
639 
645  public function getAllThings() {
646  return $this->loaded;
647  }
648 
656  protected static function processAutoLoader( $dir, array $files ) {
657  // Make paths absolute, relative to the JSON file
658  foreach ( $files as &$file ) {
659  $file = "$dir/$file";
660  }
661  return $files;
662  }
663 
668  public function setSettingsBuilder( SettingsBuilder $settingsBuilder ) {
669  $this->settingsBuilder = $settingsBuilder;
670  }
671 
672  private function getSettingsBuilder(): SettingsBuilder {
673  if ( $this->settingsBuilder === null ) {
674  $this->settingsBuilder = SettingsBuilder::getInstance();
675  }
676  return $this->settingsBuilder;
677  }
678 }
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'))
Definition: WebStart.php:88
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:85
get( $key, $flags=0)
Get an item.
makeGlobalKey( $collection,... $components)
Make a cache key for the default keyspace and given components.
set( $key, $value, $exptime=0, $flags=0)
Set an item.
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.
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:32
Builder class for constructing a Config object from a set of sources during bootstrap.
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.
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42