3 use MediaWiki\Json\FormatJson
;
4 use MediaWiki\Maintenance\Maintenance
;
5 use MediaWiki\Registration\ExtensionProcessor
;
6 use MediaWiki\Registration\ExtensionRegistry
;
7 use Wikimedia\Composer\ComposerJson
;
9 // @codeCoverageIgnoreStart
10 require_once __DIR__
. '/Maintenance.php';
11 // @codeCoverageIgnoreEnd
13 class ConvertExtensionToRegistration
extends Maintenance
{
15 private const CUSTOM_GLOBALS
= [
16 'MessagesDirs' => 'handleMessagesDirs',
17 'ExtensionMessagesFiles' => 'handleExtensionMessagesFiles',
18 'AutoloadClasses' => 'removeAbsolutePath',
19 'ExtensionCredits' => 'handleCredits',
20 'ResourceModules' => 'handleResourceModules',
21 'ResourceModuleSkinStyles' => 'handleResourceModules',
22 'Hooks' => 'handleHooks',
23 'ExtensionFunctions' => 'handleExtensionFunctions',
24 'ParserTestFiles' => 'removeAutodiscoveredParserTestFiles',
28 * Things that were formerly globals and should still be converted
30 private const FORMER_GLOBALS
= [
35 * No longer supported globals (with reason) should not be converted and emit a warning
37 private const NO_LONGER_SUPPORTED_GLOBALS
= [
38 'SpecialPageGroups' => 'deprecated', // Deprecated 1.21, removed in 1.26
42 * Keys that should be put at the top of the generated JSON file (T86608)
44 private const PROMOTE_ATTRIBUTES
= [
58 private bool $hasWarning = false;
60 public function __construct() {
61 parent
::__construct();
62 $this->addDescription( 'Converts extension entry points to the new JSON registration format' );
63 $this->addArg( 'path', 'Location to the PHP entry point you wish to convert',
64 /* $required = */ true );
65 $this->addOption( 'skin', 'Whether to write to skin.json', false, false );
66 $this->addOption( 'config-prefix', 'Custom prefix for configuration settings', false, true );
69 protected function getAllGlobals() {
70 $processor = new ReflectionClass( ExtensionProcessor
::class );
71 $settings = $processor->getProperty( 'globalSettings' );
72 $settings->setAccessible( true );
73 return array_merge( $settings->getValue(), self
::FORMER_GLOBALS
);
76 public function execute() {
77 // Extensions will do stuff like $wgResourceModules += array(...) which is a
78 // fatal unless an array is already set. So set an empty value.
79 // And use the weird $__settings name to avoid any conflicts
80 // with real poorly named settings.
81 $__settings = array_merge( $this->getAllGlobals(), array_keys( self
::CUSTOM_GLOBALS
) );
82 foreach ( $__settings as $var ) {
87 $arg = $this->getArg( 0 );
88 if ( !is_file( $arg ) ) {
89 $this->fatalError( "$arg is not a file." );
93 // Try not to create any local variables before this line
94 $vars = get_defined_vars();
95 unset( $vars['this'] );
96 unset( $vars['__settings'] );
97 $this->dir
= dirname( realpath( $this->getArg( 0 ) ) );
99 $globalSettings = $this->getAllGlobals();
100 $configPrefix = $this->getOption( 'config-prefix', 'wg' );
101 if ( $configPrefix !== 'wg' ) {
102 $this->json
['config']['_prefix'] = $configPrefix;
105 foreach ( $vars as $name => $value ) {
106 $realName = substr( $name, 2 ); // Strip 'wg'
107 if ( $realName === false ) {
111 // If it's an empty array that we likely set, skip it
112 if ( is_array( $value ) && count( $value ) === 0 && in_array( $realName, $__settings ) ) {
116 if ( isset( self
::CUSTOM_GLOBALS
[$realName] ) ) {
117 call_user_func_array( [ $this, self
::CUSTOM_GLOBALS
[$realName] ],
118 [ $realName, $value, $vars ] );
119 } elseif ( in_array( $realName, $globalSettings ) ) {
120 $this->json
[$realName] = $value;
121 } elseif ( array_key_exists( $realName, self
::NO_LONGER_SUPPORTED_GLOBALS
) ) {
122 $this->output( 'Warning: Skipped global "' . $name . '" (' .
123 self
::NO_LONGER_SUPPORTED_GLOBALS
[$realName] . '). ' .
124 "Please update the entry point before convert to registration.\n" );
125 $this->hasWarning
= true;
126 } elseif ( strpos( $name, $configPrefix ) === 0 ) {
127 $configName = substr( $name, strlen( $configPrefix ) );
130 if ( is_array( $value ) ) {
131 foreach ( $value as $k => $v ) {
132 if ( strpos( $v, $this->dir
) !== false ) {
133 $value[$k] = $this->stripPath( $v, $this->dir
);
137 } elseif ( is_string( $value ) && strpos( $value, $this->dir
) !== false ) {
138 $value = $this->stripPath( $value, $this->dir
);
142 // Most likely a config setting
143 $this->json
['config'][$configName] = [ 'value' => $value ];
146 $this->json
['config'][$configName]['path'] = true;
148 } elseif ( $configPrefix !== 'wg' && strpos( $name, 'wg' ) === 0 ) {
150 $this->output( 'Warning: Skipped global "' . $name . '" (' .
151 'config prefix is "' . $configPrefix . '"). ' .
152 "Please check that this setting isn't needed.\n" );
156 // check, if the extension requires composer libraries
157 if ( $this->needsComposerAutoloader( dirname( $this->getArg( 0 ) ) ) ) {
158 // set the load composer autoloader automatically property
159 $this->output( "Detected composer dependencies, setting 'load_composer_autoloader' to true.\n" );
160 $this->json
['load_composer_autoloader'] = true;
163 // Move some keys to the top
165 foreach ( self
::PROMOTE_ATTRIBUTES
as $key ) {
166 if ( isset( $this->json
[$key] ) ) {
167 $out[$key] = $this->json
[$key];
168 unset( $this->json
[$key] );
171 // Set a requirement on the MediaWiki version that the current MANIFEST_VERSION
172 // was introduced in.
174 ExtensionRegistry
::MEDIAWIKI_CORE
=> ExtensionRegistry
::MANIFEST_VERSION_MW_VERSION
177 // Put this at the bottom
178 $out['manifest_version'] = ExtensionRegistry
::MANIFEST_VERSION
;
179 $type = $this->hasOption( 'skin' ) ?
'skin' : 'extension';
180 $fname = "{$this->dir}/$type.json";
181 $prettyJSON = FormatJson
::encode( $out, "\t", FormatJson
::ALL_OK
);
182 file_put_contents( $fname, $prettyJSON . "\n" );
183 $this->output( "Wrote output to $fname.\n" );
184 if ( $this->hasWarning
) {
185 $this->output( "Found warnings! Please resolve the warnings and rerun this script.\n" );
189 protected function handleExtensionFunctions( $realName, $value ) {
190 foreach ( $value as $func ) {
191 if ( $func instanceof Closure
) {
192 $this->fatalError( "Error: Closures cannot be converted to JSON. " .
193 "Please move your extension function somewhere else."
195 } elseif ( function_exists( $func ) ) {
196 // check if $func exists in the global scope
197 $this->fatalError( "Error: Global functions cannot be converted to JSON. " .
198 "Please move your extension function ($func) into a class."
203 $this->json
[$realName] = $value;
206 protected function handleMessagesDirs( $realName, $value ) {
207 foreach ( $value as $key => $dirs ) {
208 foreach ( (array)$dirs as $dir ) {
209 $this->json
[$realName][$key][] = $this->stripPath( $dir, $this->dir
);
214 protected function handleExtensionMessagesFiles( $realName, $value, $vars ) {
215 foreach ( $value as $key => $file ) {
216 $strippedFile = $this->stripPath( $file, $this->dir
);
217 if ( isset( $vars['wgMessagesDirs'][$key] ) ) {
219 "Note: Ignoring PHP shim $strippedFile. " .
220 "If your extension no longer supports versions of MediaWiki " .
221 "older than 1.23.0, you can safely delete it.\n"
224 $this->json
[$realName][$key] = $strippedFile;
229 private function stripPath( $val, $dir ) {
230 if ( $val === $dir ) {
232 } elseif ( strpos( $val, $dir ) === 0 ) {
233 // +1 is for the trailing / that won't be in $this->dir
234 $val = substr( $val, strlen( $dir ) +
1 );
240 protected function removeAbsolutePath( $realName, $value ) {
242 foreach ( $value as $key => $val ) {
243 $out[$key] = $this->stripPath( $val, $this->dir
);
245 $this->json
[$realName] = $out;
248 protected function removeAutodiscoveredParserTestFiles( $realName, $value ) {
250 foreach ( $value as $key => $val ) {
251 $path = $this->stripPath( $val, $this->dir
);
252 // When path starts with tests/parser/ the file would be autodiscovered with
253 // extension registry, so no need to add it to extension.json
254 if ( !str_starts_with( $path, 'tests/parser/' ) ||
!str_ends_with( $path, '.txt' ) ) {
258 // in the best case all entries are filtered out
260 $this->json
[$realName] = $out;
262 // FIXME: the ParserTestFiles key was deprecated in
263 // MW 1.30 and removed in MW 1.40. If not all entries were filtered
264 // out by the above, we *should* recommend the user move the
265 // parser tests under `tests/parser` *not* generate an extension.json
266 // with a ParserTestFiles key that will no longer validate.
269 protected function handleCredits( $realName, $value ) {
270 $keys = array_keys( $value );
271 $this->json
['type'] = $keys[0];
272 $values = array_values( $value );
273 foreach ( $values[0][0] as $name => $val ) {
274 if ( $name !== 'path' ) {
275 $this->json
[$name] = $val;
280 public function handleHooks( $realName, $value ) {
281 foreach ( $value as $hookName => &$handlers ) {
282 if ( $hookName === 'UnitTestsList' ) {
283 $this->output( "Note: the UnitTestsList hook is no longer necessary as " .
284 "long as your tests are located in the \"tests/phpunit/\" directory. " .
285 "Please see <https://www.mediawiki.org/wiki/Manual:PHP_unit_testing/" .
286 "Writing_unit_tests_for_extensions#Register_your_tests> for more details.\n"
289 foreach ( $handlers as $func ) {
290 if ( $func instanceof Closure
) {
291 $this->fatalError( "Error: Closures cannot be converted to JSON. " .
292 "Please move the handler for $hookName somewhere else."
294 } elseif ( function_exists( $func ) ) {
295 // Check if $func exists in the global scope
296 $this->fatalError( "Error: Global functions cannot be converted to JSON. " .
297 "Please move the handler for $hookName inside a class."
301 if ( count( $handlers ) === 1 ) {
302 $handlers = $handlers[0];
305 $this->json
[$realName] = $value;
309 * @param string $realName
310 * @param array[] $value
312 protected function handleResourceModules( $realName, $value ) {
314 $remote = $this->hasOption( 'skin' ) ?
'remoteSkinPath' : 'remoteExtPath';
315 foreach ( $value as $name => $data ) {
316 if ( isset( $data['localBasePath'] ) ) {
317 $data['localBasePath'] = $this->stripPath( $data['localBasePath'], $this->dir
);
319 $defaults['localBasePath'] = $data['localBasePath'];
320 unset( $data['localBasePath'] );
321 if ( isset( $data[$remote] ) ) {
322 $defaults[$remote] = $data[$remote];
323 unset( $data[$remote] );
326 if ( $data['localBasePath'] === $defaults['localBasePath'] ) {
327 unset( $data['localBasePath'] );
329 if ( isset( $data[$remote] ) && isset( $defaults[$remote] )
330 && $data[$remote] === $defaults[$remote]
332 unset( $data[$remote] );
337 $this->json
[$realName][$name] = $data;
340 $this->json
['ResourceFileModulePaths'] = $defaults;
344 protected function needsComposerAutoloader( $path ) {
345 $path .= '/composer.json';
346 if ( file_exists( $path ) ) {
347 // assume that the composer.json file is in the root of the extension path
348 $composerJson = new ComposerJson( $path );
349 // check if there are some dependencies in the require section
350 if ( $composerJson->getRequiredDependencies() ) {
358 // @codeCoverageIgnoreStart
359 $maintClass = ConvertExtensionToRegistration
::class;
360 require_once RUN_MAINTENANCE_IF_MAIN
;
361 // @codeCoverageIgnoreEnd