Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.05% covered (warning)
86.05%
296 / 344
55.56% covered (warning)
55.56%
15 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExtensionProcessor
86.05% covered (warning)
86.05%
296 / 344
55.56% covered (warning)
55.56%
15 / 27
223.96
0.00% covered (danger)
0.00%
0 / 1
 extractInfoFromFile
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 extractInfo
84.78% covered (warning)
84.78%
39 / 46
0.00% covered (danger)
0.00%
0 / 1
15.79
 extractAttributes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getExtractedInfo
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 getRequirements
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
13
 setArrayHookHandler
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 setStringHookHandler
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 extractHooks
61.11% covered (warning)
61.11%
11 / 18
0.00% covered (danger)
0.00%
0 / 1
13.76
 extractNamespaces
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
18.63
 extractResourceLoaderModules
70.97% covered (warning)
70.97%
22 / 31
0.00% covered (danger)
0.00%
0 / 1
22.26
 extractExtensionMessagesFiles
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 extractMessagesDirs
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 extractTranslationAliasesDirs
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
5.67
 extractSkins
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 extractImplicitRights
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 extractSkinImportPaths
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
5.67
 extractCredits
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 extractForeignResourcesDir
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 extractConfig1
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 applyPath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 extractConfig2
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
9.16
 addConfigGlobal
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
5.16
 extractPathBasedGlobal
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 storeToArrayRecursive
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 storeToArray
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getExtractedAutoloadInfo
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
3.58
 extractAutoload
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2
3use MediaWiki\MainConfigNames;
4use MediaWiki\ResourceLoader\FilePath;
5
6/**
7 * Load extension manifests and then aggregate their contents.
8 *
9 * @ingroup ExtensionRegistry
10 * @newable since 1.39
11 */
12class ExtensionProcessor implements Processor {
13
14    /**
15     * Keys that should be set to $GLOBALS
16     *
17     * @var array
18     */
19    protected static $globalSettings = [
20        MainConfigNames::ActionFilteredLogs,
21        MainConfigNames::Actions,
22        MainConfigNames::AddGroups,
23        MainConfigNames::APIFormatModules,
24        MainConfigNames::APIListModules,
25        MainConfigNames::APIMetaModules,
26        MainConfigNames::APIModules,
27        MainConfigNames::APIPropModules,
28        MainConfigNames::AuthManagerAutoConfig,
29        MainConfigNames::AvailableRights,
30        MainConfigNames::CentralIdLookupProviders,
31        MainConfigNames::ChangeCredentialsBlacklist,
32        MainConfigNames::ConditionalUserOptions,
33        MainConfigNames::ConfigRegistry,
34        MainConfigNames::ContentHandlers,
35        MainConfigNames::DefaultUserOptions,
36        MainConfigNames::ExtensionEntryPointListFiles,
37        MainConfigNames::ExtensionFunctions,
38        MainConfigNames::FeedClasses,
39        MainConfigNames::FileExtensions,
40        MainConfigNames::FilterLogTypes,
41        MainConfigNames::GrantPermissionGroups,
42        MainConfigNames::GrantPermissions,
43        MainConfigNames::GrantRiskGroups,
44        MainConfigNames::GroupPermissions,
45        MainConfigNames::GroupsAddToSelf,
46        MainConfigNames::GroupsRemoveFromSelf,
47        MainConfigNames::HiddenPrefs,
48        MainConfigNames::ImplicitGroups,
49        MainConfigNames::JobClasses,
50        MainConfigNames::LogActions,
51        MainConfigNames::LogActionsHandlers,
52        MainConfigNames::LogHeaders,
53        MainConfigNames::LogNames,
54        MainConfigNames::LogRestrictions,
55        MainConfigNames::LogTypes,
56        MainConfigNames::MediaHandlers,
57        MainConfigNames::PasswordPolicy,
58        MainConfigNames::PrivilegedGroups,
59        MainConfigNames::RateLimits,
60        MainConfigNames::RawHtmlMessages,
61        MainConfigNames::ReauthenticateTime,
62        MainConfigNames::RecentChangesFlags,
63        MainConfigNames::RemoveCredentialsBlacklist,
64        MainConfigNames::RemoveGroups,
65        MainConfigNames::ResourceLoaderSources,
66        MainConfigNames::RevokePermissions,
67        MainConfigNames::SessionProviders,
68        MainConfigNames::SpecialPages,
69        MainConfigNames::UserRegistrationProviders,
70    ];
71
72    /**
73     * Top-level attributes that come from MW core
74     *
75     * @var string[]
76     */
77    protected const CORE_ATTRIBS = [
78        'ParsoidModules',
79        'RestRoutes',
80        'SkinOOUIThemes',
81        'SkinCodexThemes',
82        'SearchMappings',
83        'TrackingCategories',
84        'LateJSConfigVarNames',
85        'TempUserSerialProviders',
86        'TempUserSerialMappings',
87        'DatabaseVirtualDomains',
88    ];
89
90    /**
91     * Mapping of global settings to their specific merge strategies.
92     *
93     * @see ExtensionRegistry::exportExtractedData
94     * @see getExtractedInfo
95     * @var array
96     */
97    protected const MERGE_STRATEGIES = [
98        'wgAuthManagerAutoConfig' => 'array_plus_2d',
99        'wgCapitalLinkOverrides' => 'array_plus',
100        'wgExtraGenderNamespaces' => 'array_plus',
101        'wgGrantPermissions' => 'array_plus_2d',
102        'wgGroupPermissions' => 'array_plus_2d',
103        'wgHooks' => 'array_merge_recursive',
104        'wgNamespaceContentModels' => 'array_plus',
105        'wgNamespaceProtection' => 'array_plus',
106        'wgNamespacesWithSubpages' => 'array_plus',
107        'wgPasswordPolicy' => 'array_merge_recursive',
108        'wgRateLimits' => 'array_plus_2d',
109        'wgRevokePermissions' => 'array_plus_2d',
110    ];
111
112    /**
113     * Keys that are part of the extension credits
114     *
115     * @var array
116     */
117    protected const CREDIT_ATTRIBS = [
118        'type',
119        'author',
120        'description',
121        'descriptionmsg',
122        'license-name',
123        'name',
124        'namemsg',
125        'url',
126        'version',
127    ];
128
129    /**
130     * Things that are not 'attributes', and are not in
131     * $globalSettings or CREDIT_ATTRIBS.
132     *
133     * @var array
134     */
135    protected const NOT_ATTRIBS = [
136        'callback',
137        'config',
138        'config_prefix',
139        'load_composer_autoloader',
140        'manifest_version',
141        'namespaces',
142        'requires',
143        'AutoloadClasses',
144        'AutoloadNamespaces',
145        'ExtensionMessagesFiles',
146        'TranslationAliasesDirs',
147        'ForeignResourcesDir',
148        'Hooks',
149        'MessagePosterModule',
150        'MessagesDirs',
151        'OOUIThemePaths',
152        'QUnitTestModule',
153        'ResourceFileModulePaths',
154        'ResourceModuleSkinStyles',
155        'ResourceModules',
156        'ServiceWiringFiles',
157    ];
158
159    /**
160     * Stuff that is going to be set to $GLOBALS
161     *
162     * Some keys are pre-set to arrays, so we can += to them
163     *
164     * @var array
165     */
166    protected $globals = [
167        'wgExtensionMessagesFiles' => [],
168        'wgMessagesDirs' => [],
169        'TranslationAliasesDirs' => [],
170    ];
171
172    /**
173     * Things that should be define()'d
174     *
175     * @var array
176     */
177    protected $defines = [];
178
179    /**
180     * Things to be called once the registration of these extensions is done
181     *
182     * Keyed by the name of the extension that it belongs to
183     *
184     * @var callable[]
185     */
186    protected $callbacks = [];
187
188    /**
189     * @var array
190     */
191    protected $credits = [];
192
193    /**
194     * Autoloader information.
195     * Each element is an array of strings.
196     * 'files' is just a list, 'classes' and 'namespaces' are associative.
197     *
198     * @var string[][]
199     */
200    protected $autoload = [
201        'files' => [],
202        'classes' => [],
203        'namespaces' => [],
204    ];
205
206    /**
207     * Autoloader information for development.
208     * Same structure as $autoload.
209     *
210     * @var string[][]
211     */
212    protected $autoloadDev = [
213        'files' => [],
214        'classes' => [],
215        'namespaces' => [],
216    ];
217
218    /**
219     * Anything else in the $info that hasn't
220     * already been processed
221     *
222     * @var array
223     */
224    protected $attributes = [];
225
226    /**
227     * Extension attributes, keyed by name =>
228     *  settings.
229     *
230     * @var array
231     */
232    protected $extAttributes = [];
233
234    /**
235     * Extracts extension info from the given JSON file.
236     *
237     * @param string $path
238     *
239     * @return void
240     */
241    public function extractInfoFromFile( string $path ) {
242        $json = file_get_contents( $path );
243        $info = json_decode( $json, true );
244
245        if ( !$info ) {
246            throw new RuntimeException( "Failed to load JSON data from $path" );
247        }
248
249        $this->extractInfo( $path, $info, $info['manifest_version'] );
250    }
251
252    /**
253     * @param string $path
254     * @param array $info
255     * @param int $version manifest_version for info
256     */
257    public function extractInfo( $path, array $info, $version ) {
258        $dir = dirname( $path );
259        $this->extractHooks( $info, $path );
260        $this->extractExtensionMessagesFiles( $dir, $info );
261        $this->extractMessagesDirs( $dir, $info );
262        $this->extractTranslationAliasesDirs( $dir, $info );
263        $this->extractSkins( $dir, $info );
264        $this->extractSkinImportPaths( $dir, $info );
265        $this->extractNamespaces( $info );
266        $this->extractImplicitRights( $info );
267        $this->extractResourceLoaderModules( $dir, $info );
268        if ( isset( $info['ServiceWiringFiles'] ) ) {
269            $this->extractPathBasedGlobal(
270                'wgServiceWiringFiles',
271                $dir,
272                $info['ServiceWiringFiles']
273            );
274        }
275        $name = $this->extractCredits( $path, $info );
276        if ( isset( $info['callback'] ) ) {
277            $this->callbacks[$name] = $info['callback'];
278        }
279
280        $this->extractAutoload( $info, $dir );
281
282        // config should be after all core globals are extracted,
283        // so duplicate setting detection will work fully
284        if ( $version >= 2 ) {
285            $this->extractConfig2( $info, $dir );
286        } else {
287            // $version === 1
288            $this->extractConfig1( $info );
289        }
290
291        // Record the extension name in the ParsoidModules property
292        if ( isset( $info['ParsoidModules'] ) ) {
293            foreach ( $info['ParsoidModules'] as &$module ) {
294                if ( is_string( $module ) ) {
295                    $className = $module;
296                    $module = [
297                        'class' => $className,
298                    ];
299                }
300                $module['name'] = $name;
301            }
302        }
303
304        $this->extractForeignResourcesDir( $info, $name, $dir );
305
306        if ( $version >= 2 ) {
307            $this->extractAttributes( $path, $info );
308        }
309
310        foreach ( $info as $key => $val ) {
311            // If it's a global setting,
312            if ( in_array( $key, self::$globalSettings ) ) {
313                $this->storeToArrayRecursive( $path, "wg$key", $val, $this->globals );
314                continue;
315            }
316            // Ignore anything that starts with a @
317            if ( $key[0] === '@' ) {
318                continue;
319            }
320
321            if ( $version >= 2 ) {
322                // Only allowed attributes are set
323                if ( in_array( $key, self::CORE_ATTRIBS ) ) {
324                    $this->storeToArray( $path, $key, $val, $this->attributes );
325                }
326            } else {
327                // version === 1
328                if ( !in_array( $key, self::NOT_ATTRIBS )
329                    && !in_array( $key, self::CREDIT_ATTRIBS )
330                ) {
331                    // If it's not disallowed, it's an attribute
332                    $this->storeToArrayRecursive( $path, $key, $val, $this->attributes );
333                }
334            }
335        }
336    }
337
338    /**
339     * @param string $path
340     * @param array $info
341     */
342    protected function extractAttributes( $path, array $info ) {
343        if ( isset( $info['attributes'] ) ) {
344            foreach ( $info['attributes'] as $extName => $value ) {
345                $this->storeToArrayRecursive( $path, $extName, $value, $this->extAttributes );
346            }
347        }
348    }
349
350    public function getExtractedInfo( bool $includeDev = false ) {
351        // Make sure the merge strategies are set
352        foreach ( $this->globals as $key => $val ) {
353            if ( isset( self::MERGE_STRATEGIES[$key] ) ) {
354                $this->globals[$key][ExtensionRegistry::MERGE_STRATEGY] = self::MERGE_STRATEGIES[$key];
355            }
356        }
357
358        // Merge $this->extAttributes into $this->attributes depending on what is loaded
359        foreach ( $this->extAttributes as $extName => $value ) {
360            // Only set the attribute if $extName is loaded (and hence present in credits)
361            if ( isset( $this->credits[$extName] ) ) {
362                foreach ( $value as $attrName => $attrValue ) {
363                    $this->storeToArrayRecursive(
364                        '', // Don't provide a path since it's impossible to generate an error here
365                        $extName . $attrName,
366                        $attrValue,
367                        $this->attributes
368                    );
369                }
370                unset( $this->extAttributes[$extName] );
371            }
372        }
373
374        $autoload = $this->getExtractedAutoloadInfo( $includeDev );
375        return [
376            'globals' => $this->globals,
377            'defines' => $this->defines,
378            'callbacks' => $this->callbacks,
379            'credits' => $this->credits,
380            'attributes' => $this->attributes,
381            'autoloaderPaths' => $autoload['files'],
382            'autoloaderClasses' => $autoload['classes'],
383            'autoloaderNS' => $autoload['namespaces'],
384        ];
385    }
386
387    public function getRequirements( array $info, $includeDev ) {
388        // Quick shortcuts
389        if ( !$includeDev || !isset( $info['dev-requires'] ) ) {
390            return $info['requires'] ?? [];
391        }
392
393        if ( !isset( $info['requires'] ) ) {
394            return $info['dev-requires'] ?? [];
395        }
396
397        // OK, we actually have to merge everything
398        $merged = [];
399
400        // Helper that combines version requirements by
401        // picking the non-null if one is, or combines
402        // the two. Note that it is not possible for
403        // both inputs to be null.
404        $pick = static function ( $a, $b ) {
405            if ( $a === null ) {
406                return $b;
407            } elseif ( $b === null ) {
408                return $a;
409            } else {
410                return "$a $b";
411            }
412        };
413
414        $req = $info['requires'];
415        $dev = $info['dev-requires'];
416        if ( isset( $req['MediaWiki'] ) || isset( $dev['MediaWiki'] ) ) {
417            $merged['MediaWiki'] = $pick(
418                $req['MediaWiki'] ?? null,
419                $dev['MediaWiki'] ?? null
420            );
421        }
422
423        $platform = array_merge(
424            array_keys( $req['platform'] ?? [] ),
425            array_keys( $dev['platform'] ?? [] )
426        );
427        if ( $platform ) {
428            foreach ( $platform as $pkey ) {
429                if ( $pkey === 'php' ) {
430                    $value = $pick(
431                        $req['platform']['php'] ?? null,
432                        $dev['platform']['php'] ?? null
433                    );
434                } else {
435                    // Prefer dev value, but these should be constant
436                    // anyway (ext-* and ability-*)
437                    $value = $dev['platform'][$pkey] ?? $req['platform'][$pkey];
438                }
439                $merged['platform'][$pkey] = $value;
440            }
441        }
442
443        foreach ( [ 'extensions', 'skins' ] as $thing ) {
444            $things = array_merge(
445                array_keys( $req[$thing] ?? [] ),
446                array_keys( $dev[$thing] ?? [] )
447            );
448            foreach ( $things as $name ) {
449                $merged[$thing][$name] = $pick(
450                    $req[$thing][$name] ?? null,
451                    $dev[$thing][$name] ?? null
452                );
453            }
454        }
455        return $merged;
456    }
457
458    /**
459     * When handler value is an array, set $wgHooks or Hooks attribute
460     * Could be legacy hook e.g. 'GlobalFunctionName' or non-legacy hook
461     * referencing a handler definition from 'HookHandler' attribute
462     *
463     * @param array $callback Handler
464     * @param array $hookHandlersAttr handler definitions from 'HookHandler' attribute
465     * @param string $name
466     * @param string $path extension.json file path
467     * @throws UnexpectedValueException
468     */
469    private function setArrayHookHandler(
470        array $callback,
471        array $hookHandlersAttr,
472        string $name,
473        string $path
474    ) {
475        if ( isset( $callback['handler'] ) ) {
476            $handlerName = $callback['handler'];
477            $handlerDefinition = $hookHandlersAttr[$handlerName] ?? false;
478            if ( !$handlerDefinition ) {
479                throw new UnexpectedValueException(
480                    "Missing handler definition for $name in HookHandlers attribute in $path"
481                );
482            }
483            $callback['handler'] = $handlerDefinition;
484            $callback['extensionPath'] = $path;
485            $this->attributes['Hooks'][$name][] = $callback;
486        } else {
487            foreach ( $callback as $callable ) {
488                if ( is_array( $callable ) ) {
489                    if ( isset( $callable['handler'] ) ) { // Non-legacy style handler
490                        $this->setArrayHookHandler( $callable, $hookHandlersAttr, $name, $path );
491                    } else { // Legacy style handler array
492                        $this->globals['wgHooks'][$name][] = $callable;
493                    }
494                } elseif ( is_string( $callable ) ) {
495                    $this->setStringHookHandler( $callable, $hookHandlersAttr, $name, $path );
496                }
497            }
498        }
499    }
500
501    /**
502     * When handler value is a string, set $wgHooks or Hooks attribute.
503     * Could be legacy hook e.g. 'GlobalFunctionName' or non-legacy hook
504     * referencing a handler definition from 'HookHandler' attribute
505     *
506     * @param string $callback Handler
507     * @param array $hookHandlersAttr handler definitions from 'HookHandler' attribute
508     * @param string $name
509     * @param string $path
510     */
511    private function setStringHookHandler(
512        string $callback,
513        array $hookHandlersAttr,
514        string $name,
515        string $path
516    ) {
517        if ( isset( $hookHandlersAttr[$callback] ) ) {
518            $handler = [
519                'handler' => $hookHandlersAttr[$callback],
520                'extensionPath' => $path
521            ];
522            $this->attributes['Hooks'][$name][] = $handler;
523        } else { // legacy style handler
524            $this->globals['wgHooks'][$name][] = $callback;
525        }
526    }
527
528    /**
529     * Extract hook information from Hooks and HookHandler attributes.
530     * Store hook in $wgHooks if a legacy style handler or the 'Hooks' attribute if
531     * a non-legacy handler
532     *
533     * @param array $info attributes and associated values from extension.json
534     * @param string $path path to extension.json
535     */
536    protected function extractHooks( array $info, string $path ) {
537        $extName = $info['name'];
538        if ( isset( $info['Hooks'] ) ) {
539            $hookHandlersAttr = [];
540            foreach ( $info['HookHandlers'] ?? [] as $name => $def ) {
541                $hookHandlersAttr[$name] = [ 'name' => "$extName-$name" ] + $def;
542            }
543            foreach ( $info['Hooks'] as $name => $callback ) {
544                if ( is_string( $callback ) ) {
545                    $this->setStringHookHandler( $callback, $hookHandlersAttr, $name, $path );
546                } elseif ( is_array( $callback ) ) {
547                    $this->setArrayHookHandler( $callback, $hookHandlersAttr, $name, $path );
548                }
549            }
550        }
551        if ( isset( $info['DeprecatedHooks'] ) ) {
552            $deprecatedHooks = [];
553            foreach ( $info['DeprecatedHooks'] as $name => $deprecatedHookInfo ) {
554                $deprecatedHookInfo += [ 'component' => $extName ];
555                $deprecatedHooks[$name] = $deprecatedHookInfo;
556            }
557            if ( isset( $this->attributes['DeprecatedHooks'] ) ) {
558                $this->attributes['DeprecatedHooks'] += $deprecatedHooks;
559            } else {
560                $this->attributes['DeprecatedHooks'] = $deprecatedHooks;
561            }
562        }
563    }
564
565    /**
566     * Register namespaces with the appropriate global settings
567     *
568     * @param array $info
569     */
570    protected function extractNamespaces( array $info ) {
571        if ( isset( $info['namespaces'] ) ) {
572            foreach ( $info['namespaces'] as $ns ) {
573                if ( defined( $ns['constant'] ) ) {
574                    // If the namespace constant is already defined, use it.
575                    // This allows namespace IDs to be overwritten locally.
576                    $id = constant( $ns['constant'] );
577                } else {
578                    $id = $ns['id'];
579                }
580                $this->defines[ $ns['constant'] ] = $id;
581
582                if ( !( isset( $ns['conditional'] ) && $ns['conditional'] ) ) {
583                    // If it is not conditional, register it
584                    $this->attributes['ExtensionNamespaces'][$id] = $ns['name'];
585                }
586                if ( isset( $ns['movable'] ) && !$ns['movable'] ) {
587                    $this->attributes['ImmovableNamespaces'][] = $id;
588                }
589                if ( isset( $ns['gender'] ) ) {
590                    $this->globals['wgExtraGenderNamespaces'][$id] = $ns['gender'];
591                }
592                if ( isset( $ns['subpages'] ) && $ns['subpages'] ) {
593                    $this->globals['wgNamespacesWithSubpages'][$id] = true;
594                }
595                if ( isset( $ns['content'] ) && $ns['content'] ) {
596                    $this->globals['wgContentNamespaces'][] = $id;
597                }
598                if ( isset( $ns['defaultcontentmodel'] ) ) {
599                    $this->globals['wgNamespaceContentModels'][$id] = $ns['defaultcontentmodel'];
600                }
601                if ( isset( $ns['protection'] ) ) {
602                    $this->globals['wgNamespaceProtection'][$id] = $ns['protection'];
603                }
604                if ( isset( $ns['capitallinkoverride'] ) ) {
605                    $this->globals['wgCapitalLinkOverrides'][$id] = $ns['capitallinkoverride'];
606                }
607                if ( isset( $ns['includable'] ) && !$ns['includable'] ) {
608                    $this->globals['wgNonincludableNamespaces'][] = $id;
609                }
610            }
611        }
612    }
613
614    protected function extractResourceLoaderModules( $dir, array $info ) {
615        $defaultPaths = $info['ResourceFileModulePaths'] ?? false;
616        if ( isset( $defaultPaths['localBasePath'] ) ) {
617            if ( $defaultPaths['localBasePath'] === '' ) {
618                // Avoid double slashes (e.g. /extensions/Example//path)
619                $defaultPaths['localBasePath'] = $dir;
620            } else {
621                $defaultPaths['localBasePath'] = "$dir/{$defaultPaths['localBasePath']}";
622            }
623        }
624
625        foreach ( [ 'ResourceModules', 'ResourceModuleSkinStyles', 'OOUIThemePaths' ] as $setting ) {
626            if ( isset( $info[$setting] ) ) {
627                foreach ( $info[$setting] as $name => $data ) {
628                    if ( isset( $data['localBasePath'] ) ) {
629                        if ( $data['localBasePath'] === '' ) {
630                            // Avoid double slashes (e.g. /extensions/Example//path)
631                            $data['localBasePath'] = $dir;
632                        } else {
633                            $data['localBasePath'] = "$dir/{$data['localBasePath']}";
634                        }
635                    }
636                    if ( $defaultPaths ) {
637                        $data += $defaultPaths;
638                    }
639                    $this->attributes[$setting][$name] = $data;
640                }
641            }
642        }
643
644        if ( isset( $info['QUnitTestModule'] ) ) {
645            $data = $info['QUnitTestModule'];
646            if ( isset( $data['localBasePath'] ) ) {
647                if ( $data['localBasePath'] === '' ) {
648                    // Avoid double slashes (e.g. /extensions/Example//path)
649                    $data['localBasePath'] = $dir;
650                } else {
651                    $data['localBasePath'] = "$dir/{$data['localBasePath']}";
652                }
653            }
654            $this->attributes['QUnitTestModules']["test.{$info['name']}"] = $data;
655        }
656
657        if ( isset( $info['MessagePosterModule'] ) ) {
658            $data = $info['MessagePosterModule'];
659            $basePath = $data['localBasePath'] ?? '';
660            $baseDir = $basePath === '' ? $dir : "$dir/$basePath";
661            foreach ( $data['scripts'] ?? [] as $scripts ) {
662                $this->attributes['MessagePosterModule']['scripts'][] =
663                    new FilePath( $scripts, $baseDir );
664            }
665            foreach ( $data['dependencies'] ?? [] as $dependency ) {
666                $this->attributes['MessagePosterModule']['dependencies'][] = $dependency;
667            }
668        }
669    }
670
671    protected function extractExtensionMessagesFiles( $dir, array $info ) {
672        if ( isset( $info['ExtensionMessagesFiles'] ) ) {
673            foreach ( $info['ExtensionMessagesFiles'] as &$file ) {
674                $file = "$dir/$file";
675            }
676            $this->globals["wgExtensionMessagesFiles"] += $info['ExtensionMessagesFiles'];
677        }
678    }
679
680    /**
681     * Set message-related settings, which need to be expanded to use
682     * absolute paths
683     *
684     * @param string $dir
685     * @param array $info
686     */
687    protected function extractMessagesDirs( $dir, array $info ) {
688        if ( isset( $info['MessagesDirs'] ) ) {
689            foreach ( $info['MessagesDirs'] as $name => $files ) {
690                foreach ( (array)$files as $file ) {
691                    $this->globals["wgMessagesDirs"][$name][] = "$dir/$file";
692                }
693            }
694        }
695    }
696
697    /**
698     * Set localization related settings, which need to be expanded to use
699     * absolute paths
700     *
701     * @param string $dir
702     * @param array $info
703     */
704    protected function extractTranslationAliasesDirs( $dir, array $info ) {
705        foreach ( $info['TranslationAliasesDirs'] ?? [] as $name => $files ) {
706            foreach ( (array)$files as $file ) {
707                $this->globals['wgTranslationAliasesDirs'][$name][] = "$dir/$file";
708            }
709        }
710    }
711
712    /**
713     * Extract skins and handle path correction for templateDirectory.
714     *
715     * @param string $dir
716     * @param array $info
717     */
718    protected function extractSkins( $dir, array $info ) {
719        if ( isset( $info['ValidSkinNames'] ) ) {
720            foreach ( $info['ValidSkinNames'] as $skinKey => $data ) {
721                if ( isset( $data['args'][0] ) ) {
722                    $templateDirectory = $data['args'][0]['templateDirectory'] ?? 'templates';
723                    $data['args'][0]['templateDirectory'] = $dir . '/' . $templateDirectory;
724                }
725                $this->globals['wgValidSkinNames'][$skinKey] = $data;
726            }
727        }
728    }
729
730    /**
731     * Extract any user rights that should be granted implicitly.
732     *
733     * @param array $info
734     */
735    protected function extractImplicitRights( array $info ) {
736        // Rate limits are only configurable for rights that are either in wgImplicitRights
737        // or in wgAvailableRights. Extensions that define rate limits should not have to
738        // explicitly add them to wgImplicitRights as well, we can do that automatically.
739
740        if ( isset( $info['RateLimits'] ) ) {
741            $rights = array_keys( $info['RateLimits'] );
742
743            if ( isset( $info['AvailableRights'] ) ) {
744                $rights = array_diff( $rights, $info['AvailableRights'] );
745            }
746
747            $this->globals['wgImplicitRights'] = array_merge(
748                $this->globals['wgImplicitRights'] ?? [],
749                $rights
750            );
751        }
752    }
753
754    /**
755     * @param string $dir
756     * @param array $info
757     */
758    protected function extractSkinImportPaths( $dir, array $info ) {
759        if ( isset( $info['SkinLessImportPaths'] ) ) {
760            foreach ( $info['SkinLessImportPaths'] as $skin => $subpath ) {
761                $this->attributes['SkinLessImportPaths'][$skin] = "$dir/$subpath";
762            }
763        }
764    }
765
766    /**
767     * @param string $path
768     * @param array $info
769     * @return string Name of thing
770     * @throws Exception
771     */
772    protected function extractCredits( $path, array $info ) {
773        $credits = [
774            'path' => $path,
775            'type' => 'other',
776        ];
777        foreach ( self::CREDIT_ATTRIBS as $attr ) {
778            if ( isset( $info[$attr] ) ) {
779                $credits[$attr] = $info[$attr];
780            }
781        }
782
783        $name = $credits['name'];
784
785        // If someone is loading the same thing twice, throw
786        // a nice error (T121493)
787        if ( isset( $this->credits[$name] ) ) {
788            $firstPath = $this->credits[$name]['path'];
789            $secondPath = $credits['path'];
790            throw new InvalidArgumentException(
791                "It was attempted to load $name twice, from $firstPath and $secondPath."
792            );
793        }
794
795        $this->credits[$name] = $credits;
796
797        return $name;
798    }
799
800    protected function extractForeignResourcesDir( array $info, string $name, string $dir ): void {
801        if ( array_key_exists( 'ForeignResourcesDir', $info ) ) {
802            if ( !is_string( $info['ForeignResourcesDir'] ) ) {
803                throw new InvalidArgumentException( "Incorrect ForeignResourcesDir type, must be a string (in $name)" );
804            }
805            $this->attributes['ForeignResourcesDir'][$name] = "{$dir}/{$info['ForeignResourcesDir']}";
806        }
807    }
808
809    /**
810     * Set configuration settings for manifest_version == 1
811     * @todo In the future, this should be done via Config interfaces
812     *
813     * @param array $info
814     */
815    protected function extractConfig1( array $info ) {
816        if ( isset( $info['config'] ) ) {
817            if ( isset( $info['config']['_prefix'] ) ) {
818                $prefix = $info['config']['_prefix'];
819                unset( $info['config']['_prefix'] );
820            } else {
821                $prefix = 'wg';
822            }
823            foreach ( $info['config'] as $key => $val ) {
824                if ( $key[0] !== '@' ) {
825                    $this->addConfigGlobal( "$prefix$key", $val, $info['name'] );
826                }
827            }
828        }
829    }
830
831    /**
832     * Applies a base path to the given string or string array.
833     *
834     * @param string[] $value
835     * @param string $dir
836     *
837     * @return string[]
838     */
839    private function applyPath( array $value, string $dir ): array {
840        $result = [];
841
842        foreach ( $value as $k => $v ) {
843            $result[$k] = $dir . '/' . $v;
844        }
845        return $result;
846    }
847
848    /**
849     * Set configuration settings for manifest_version == 2
850     * @todo In the future, this should be done via Config interfaces
851     *
852     * @param array $info
853     * @param string $dir
854     */
855    protected function extractConfig2( array $info, $dir ) {
856        $prefix = $info['config_prefix'] ?? 'wg';
857        if ( isset( $info['config'] ) ) {
858            foreach ( $info['config'] as $key => $data ) {
859                if ( !array_key_exists( 'value', $data ) ) {
860                    throw new UnexpectedValueException( "Missing value for config $key" );
861                }
862
863                $value = $data['value'];
864                if ( isset( $data['path'] ) && $data['path'] ) {
865                    if ( is_array( $value ) ) {
866                        $value = $this->applyPath( $value, $dir );
867                    } else {
868                        $value = "$dir/$value";
869                    }
870                }
871                if ( isset( $data['merge_strategy'] ) ) {
872                    $value[ExtensionRegistry::MERGE_STRATEGY] = $data['merge_strategy'];
873                }
874                $this->addConfigGlobal( "$prefix$key", $value, $info['name'] );
875                $data['providedby'] = $info['name'];
876                if ( isset( $info['ConfigRegistry'][0] ) ) {
877                    $data['configregistry'] = array_keys( $info['ConfigRegistry'] )[0];
878                }
879            }
880        }
881    }
882
883    /**
884     * Helper function to set a value to a specific global config variable if it isn't set already.
885     *
886     * @param string $key The config key with the prefix and anything
887     * @param mixed $value The value of the config
888     * @param string $extName Name of the extension
889     */
890    private function addConfigGlobal( $key, $value, $extName ) {
891        if ( array_key_exists( $key, $this->globals ) ) {
892            throw new RuntimeException(
893                "The configuration setting '$key' was already set by MediaWiki core or"
894                . " another extension, and cannot be set again by $extName." );
895        }
896        if ( isset( $value[ExtensionRegistry::MERGE_STRATEGY] ) &&
897            $value[ExtensionRegistry::MERGE_STRATEGY] === 'array_merge_recursive' ) {
898            wfDeprecatedMsg(
899                "Using the array_merge_recursive merge strategy in extension.json and skin.json" .
900                " was deprecated in MediaWiki 1.42",
901                "1.42"
902            );
903        }
904        $this->globals[$key] = $value;
905    }
906
907    protected function extractPathBasedGlobal( $global, $dir, $paths ) {
908        foreach ( $paths as $path ) {
909            $this->globals[$global][] = "$dir/$path";
910        }
911    }
912
913    /**
914     * Stores $value to $array; using array_merge_recursive() if $array already contains $name
915     *
916     * @param string $path
917     * @param string $name
918     * @param array $value
919     * @param array &$array
920     * @throws InvalidArgumentException
921     */
922    protected function storeToArrayRecursive( $path, $name, $value, &$array ) {
923        if ( !is_array( $value ) ) {
924            throw new InvalidArgumentException( "The value for '$name' should be an array (from $path)" );
925        }
926        if ( isset( $array[$name] ) ) {
927            $array[$name] = array_merge_recursive( $array[$name], $value );
928        } else {
929            $array[$name] = $value;
930        }
931    }
932
933    /**
934     * Stores $value to $array; using array_merge() if $array already contains $name
935     *
936     * @param string $path
937     * @param string $name
938     * @param array $value
939     * @param array &$array
940     * @throws InvalidArgumentException
941     */
942    protected function storeToArray( $path, $name, $value, &$array ) {
943        if ( !is_array( $value ) ) {
944            throw new InvalidArgumentException( "The value for '$name' should be an array (from $path)" );
945        }
946        if ( isset( $array[$name] ) ) {
947            $array[$name] = array_merge( $array[$name], $value );
948        } else {
949            $array[$name] = $value;
950        }
951    }
952
953    /**
954     * Returns the extracted autoload info.
955     * The autoload info is returned as an associative array with three keys:
956     * - files: a list of files to load, for use with Autoloader::loadFile()
957     * - classes: a map of class names to files, for use with Autoloader::registerClass()
958     * - namespaces: a map of namespace names to directories, for use
959     *   with Autoloader::registerNamespace()
960     *
961     * @since 1.39
962     *
963     * @param bool $includeDev
964     *
965     * @return array[] The autoload info.
966     */
967    public function getExtractedAutoloadInfo( bool $includeDev = false ): array {
968        $autoload = $this->autoload;
969
970        if ( $includeDev ) {
971            $autoload['classes'] += $this->autoloadDev['classes'];
972            $autoload['namespaces'] += $this->autoloadDev['namespaces'];
973
974            // NOTE: This is here for completeness. Per MW 1.39,
975            //       $this->autoloadDev['files'] is always empty.
976            //       So avoid the performance hit of array_merge().
977            if ( !empty( $this->autoloadDev['files'] ) ) {
978                // NOTE: Don't use += with numeric keys!
979                //       Could use PHPUtils::pushArray.
980                $autoload['files'] = array_merge(
981                    $autoload['files'],
982                    $this->autoloadDev['files']
983                );
984            }
985        }
986
987        return $autoload;
988    }
989
990    /**
991     * @param array $info
992     * @param string $dir
993     */
994    private function extractAutoload( array $info, string $dir ) {
995        if ( isset( $info['load_composer_autoloader'] ) && $info['load_composer_autoloader'] === true ) {
996            $file = "$dir/vendor/autoload.php";
997            if ( file_exists( $file ) ) {
998                $this->autoload['files'][] = $file;
999            }
1000        }
1001
1002        if ( isset( $info['AutoloadClasses'] ) ) {
1003            $paths = $this->applyPath( $info['AutoloadClasses'], $dir );
1004            $this->autoload['classes'] += $paths;
1005        }
1006
1007        if ( isset( $info['AutoloadNamespaces'] ) ) {
1008            $paths = $this->applyPath( $info['AutoloadNamespaces'], $dir );
1009            $this->autoload['namespaces'] += $paths;
1010        }
1011
1012        if ( isset( $info['TestAutoloadClasses'] ) ) {
1013            $paths = $this->applyPath( $info['TestAutoloadClasses'], $dir );
1014            $this->autoloadDev['classes'] += $paths;
1015        }
1016
1017        if ( isset( $info['TestAutoloadNamespaces'] ) ) {
1018            $paths = $this->applyPath( $info['TestAutoloadNamespaces'], $dir );
1019            $this->autoloadDev['namespaces'] += $paths;
1020        }
1021    }
1022}