Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 170 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
| ConvertExtensionToRegistration | |
0.00% |
0 / 170 |
|
0.00% |
0 / 13 |
5256 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
| getAllGlobals | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| execute | |
0.00% |
0 / 73 |
|
0.00% |
0 / 1 |
702 | |||
| handleExtensionFunctions | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
| handleMessagesDirs | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
| handleExtensionMessagesFiles | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
| stripPath | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| removeAbsolutePath | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| removeAutodiscoveredParserTestFiles | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
30 | |||
| handleCredits | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
| handleHooks | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
56 | |||
| handleResourceModules | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
132 | |||
| needsComposerAutoloader | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
| 1 | <?php |
| 2 | |
| 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; |
| 8 | |
| 9 | // @codeCoverageIgnoreStart |
| 10 | require_once __DIR__ . '/Maintenance.php'; |
| 11 | // @codeCoverageIgnoreEnd |
| 12 | |
| 13 | class ConvertExtensionToRegistration extends Maintenance { |
| 14 | |
| 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', |
| 25 | ]; |
| 26 | |
| 27 | /** |
| 28 | * Things that were formerly globals and should still be converted |
| 29 | */ |
| 30 | private const FORMER_GLOBALS = [ |
| 31 | 'TrackingCategories', |
| 32 | ]; |
| 33 | |
| 34 | /** |
| 35 | * No longer supported globals (with reason) should not be converted and emit a warning |
| 36 | */ |
| 37 | private const NO_LONGER_SUPPORTED_GLOBALS = [ |
| 38 | 'SpecialPageGroups' => 'deprecated', // Deprecated 1.21, removed in 1.26 |
| 39 | ]; |
| 40 | |
| 41 | /** |
| 42 | * Keys that should be put at the top of the generated JSON file (T86608) |
| 43 | */ |
| 44 | private const PROMOTE_ATTRIBUTES = [ |
| 45 | 'name', |
| 46 | 'namemsg', |
| 47 | 'version', |
| 48 | 'author', |
| 49 | 'url', |
| 50 | 'description', |
| 51 | 'descriptionmsg', |
| 52 | 'license-name', |
| 53 | 'type', |
| 54 | ]; |
| 55 | |
| 56 | private array $json; |
| 57 | private string $dir; |
| 58 | private bool $hasWarning = false; |
| 59 | |
| 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 ); |
| 67 | } |
| 68 | |
| 69 | protected function getAllGlobals(): array { |
| 70 | $processor = new ReflectionClass( ExtensionProcessor::class ); |
| 71 | $settings = $processor->getProperty( 'globalSettings' ); |
| 72 | return array_merge( $settings->getValue(), self::FORMER_GLOBALS ); |
| 73 | } |
| 74 | |
| 75 | public function execute() { |
| 76 | // Extensions will do stuff like $wgResourceModules += array(...) which is a |
| 77 | // fatal unless an array is already set. So set an empty value. |
| 78 | // And use the weird $__settings name to avoid any conflicts |
| 79 | // with real poorly named settings. |
| 80 | $__settings = array_merge( $this->getAllGlobals(), array_keys( self::CUSTOM_GLOBALS ) ); |
| 81 | foreach ( $__settings as $var ) { |
| 82 | $var = 'wg' . $var; |
| 83 | $$var = []; |
| 84 | } |
| 85 | unset( $var ); |
| 86 | $arg = $this->getArg( 0 ); |
| 87 | if ( !is_file( $arg ) ) { |
| 88 | $this->fatalError( "$arg is not a file." ); |
| 89 | } |
| 90 | require $arg; |
| 91 | unset( $arg ); |
| 92 | // Try not to create any local variables before this line |
| 93 | $vars = get_defined_vars(); |
| 94 | unset( $vars['this'] ); |
| 95 | unset( $vars['__settings'] ); |
| 96 | $this->dir = dirname( realpath( $this->getArg( 0 ) ) ); |
| 97 | $this->json = []; |
| 98 | $globalSettings = $this->getAllGlobals(); |
| 99 | $configPrefix = $this->getOption( 'config-prefix', 'wg' ); |
| 100 | if ( $configPrefix !== 'wg' ) { |
| 101 | $this->json['config']['_prefix'] = $configPrefix; |
| 102 | } |
| 103 | |
| 104 | foreach ( $vars as $name => $value ) { |
| 105 | $realName = substr( $name, 2 ); // Strip 'wg' |
| 106 | if ( $realName === '' ) { |
| 107 | continue; |
| 108 | } |
| 109 | |
| 110 | // If it's an empty array that we likely set, skip it |
| 111 | if ( is_array( $value ) && count( $value ) === 0 && in_array( $realName, $__settings ) ) { |
| 112 | continue; |
| 113 | } |
| 114 | |
| 115 | if ( isset( self::CUSTOM_GLOBALS[$realName] ) ) { |
| 116 | $method = self::CUSTOM_GLOBALS[$realName]; |
| 117 | $this->$method( $realName, $value, $vars ); |
| 118 | } elseif ( in_array( $realName, $globalSettings ) ) { |
| 119 | $this->json[$realName] = $value; |
| 120 | } elseif ( array_key_exists( $realName, self::NO_LONGER_SUPPORTED_GLOBALS ) ) { |
| 121 | $this->output( 'Warning: Skipped global "' . $name . '" (' . |
| 122 | self::NO_LONGER_SUPPORTED_GLOBALS[$realName] . '). ' . |
| 123 | "Please update the entry point before convert to registration.\n" ); |
| 124 | $this->hasWarning = true; |
| 125 | } elseif ( str_starts_with( $name, $configPrefix ) ) { |
| 126 | $configName = substr( $name, strlen( $configPrefix ) ); |
| 127 | |
| 128 | $isPath = false; |
| 129 | if ( is_array( $value ) ) { |
| 130 | foreach ( $value as $k => $v ) { |
| 131 | if ( str_contains( $v, $this->dir ) ) { |
| 132 | $value[$k] = $this->stripPath( $v, $this->dir ); |
| 133 | $isPath = true; |
| 134 | } |
| 135 | } |
| 136 | } elseif ( is_string( $value ) && str_contains( $value, $this->dir ) ) { |
| 137 | $value = $this->stripPath( $value, $this->dir ); |
| 138 | $isPath = true; |
| 139 | } |
| 140 | |
| 141 | // Most likely a config setting |
| 142 | $this->json['config'][$configName] = [ 'value' => $value ]; |
| 143 | |
| 144 | if ( $isPath ) { |
| 145 | $this->json['config'][$configName]['path'] = true; |
| 146 | } |
| 147 | } elseif ( $configPrefix !== 'wg' && str_starts_with( $name, 'wg' ) ) { |
| 148 | // Warn about this |
| 149 | $this->output( 'Warning: Skipped global "' . $name . '" (' . |
| 150 | 'config prefix is "' . $configPrefix . '"). ' . |
| 151 | "Please check that this setting isn't needed.\n" ); |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // check, if the extension requires composer libraries |
| 156 | if ( $this->needsComposerAutoloader( dirname( $this->getArg( 0 ) ) ) ) { |
| 157 | // set the load composer autoloader automatically property |
| 158 | $this->output( "Detected composer dependencies, setting 'load_composer_autoloader' to true.\n" ); |
| 159 | $this->json['load_composer_autoloader'] = true; |
| 160 | } |
| 161 | |
| 162 | // Move some keys to the top |
| 163 | $out = []; |
| 164 | foreach ( self::PROMOTE_ATTRIBUTES as $key ) { |
| 165 | if ( isset( $this->json[$key] ) ) { |
| 166 | $out[$key] = $this->json[$key]; |
| 167 | unset( $this->json[$key] ); |
| 168 | } |
| 169 | } |
| 170 | // Set a requirement on the MediaWiki version that the current MANIFEST_VERSION |
| 171 | // was introduced in. |
| 172 | $out['requires'] = [ |
| 173 | ExtensionRegistry::MEDIAWIKI_CORE => ExtensionRegistry::MANIFEST_VERSION_MW_VERSION |
| 174 | ]; |
| 175 | $out += $this->json; |
| 176 | // Put this at the bottom |
| 177 | $out['manifest_version'] = ExtensionRegistry::MANIFEST_VERSION; |
| 178 | $type = $this->hasOption( 'skin' ) ? 'skin' : 'extension'; |
| 179 | $fname = "{$this->dir}/$type.json"; |
| 180 | $prettyJSON = FormatJson::encode( $out, "\t", FormatJson::ALL_OK ); |
| 181 | file_put_contents( $fname, $prettyJSON . "\n" ); |
| 182 | $this->output( "Wrote output to $fname.\n" ); |
| 183 | if ( $this->hasWarning ) { |
| 184 | $this->output( "Found warnings! Please resolve the warnings and rerun this script.\n" ); |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | protected function handleExtensionFunctions( string $realName, array $value ) { |
| 189 | foreach ( $value as $func ) { |
| 190 | if ( $func instanceof Closure ) { |
| 191 | $this->fatalError( "Error: Closures cannot be converted to JSON. " . |
| 192 | "Please move your extension function somewhere else." |
| 193 | ); |
| 194 | } elseif ( function_exists( $func ) ) { |
| 195 | // check if $func exists in the global scope |
| 196 | $this->fatalError( "Error: Global functions cannot be converted to JSON. " . |
| 197 | "Please move your extension function ($func) into a class." |
| 198 | ); |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | $this->json[$realName] = $value; |
| 203 | } |
| 204 | |
| 205 | protected function handleMessagesDirs( string $realName, array $value, array $_ ) { |
| 206 | foreach ( $value as $key => $dirs ) { |
| 207 | foreach ( (array)$dirs as $dir ) { |
| 208 | $this->json[$realName][$key][] = $this->stripPath( $dir, $this->dir ); |
| 209 | } |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | protected function handleExtensionMessagesFiles( string $realName, array $value, array $vars ) { |
| 214 | foreach ( $value as $key => $file ) { |
| 215 | $strippedFile = $this->stripPath( $file, $this->dir ); |
| 216 | if ( isset( $vars['wgMessagesDirs'][$key] ) ) { |
| 217 | $this->output( |
| 218 | "Note: Ignoring PHP shim $strippedFile. " . |
| 219 | "If your extension no longer supports versions of MediaWiki " . |
| 220 | "older than 1.23.0, you can safely delete it.\n" |
| 221 | ); |
| 222 | } else { |
| 223 | $this->json[$realName][$key] = $strippedFile; |
| 224 | } |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | private function stripPath( string $val, string $dir ): string { |
| 229 | if ( $val === $dir ) { |
| 230 | $val = ''; |
| 231 | } elseif ( str_starts_with( $val, $dir ) ) { |
| 232 | // +1 is for the trailing / that won't be in $this->dir |
| 233 | $val = substr( $val, strlen( $dir ) + 1 ); |
| 234 | } |
| 235 | |
| 236 | return $val; |
| 237 | } |
| 238 | |
| 239 | protected function removeAbsolutePath( string $realName, array $value ) { |
| 240 | $out = []; |
| 241 | foreach ( $value as $key => $val ) { |
| 242 | $out[$key] = $this->stripPath( $val, $this->dir ); |
| 243 | } |
| 244 | $this->json[$realName] = $out; |
| 245 | } |
| 246 | |
| 247 | protected function removeAutodiscoveredParserTestFiles( string $realName, array $value ) { |
| 248 | $out = []; |
| 249 | foreach ( $value as $key => $val ) { |
| 250 | $path = $this->stripPath( $val, $this->dir ); |
| 251 | // When path starts with tests/parser/ the file would be autodiscovered with |
| 252 | // extension registry, so no need to add it to extension.json |
| 253 | if ( !str_starts_with( $path, 'tests/parser/' ) || !str_ends_with( $path, '.txt' ) ) { |
| 254 | $out[$key] = $path; |
| 255 | } |
| 256 | } |
| 257 | // in the best case all entries are filtered out |
| 258 | if ( $out ) { |
| 259 | $this->json[$realName] = $out; |
| 260 | } |
| 261 | // FIXME: the ParserTestFiles key was deprecated in |
| 262 | // MW 1.30 and removed in MW 1.40. If not all entries were filtered |
| 263 | // out by the above, we *should* recommend the user move the |
| 264 | // parser tests under `tests/parser` *not* generate an extension.json |
| 265 | // with a ParserTestFiles key that will no longer validate. |
| 266 | } |
| 267 | |
| 268 | protected function handleCredits( string $realName, array $value ) { |
| 269 | $keys = array_keys( $value ); |
| 270 | $this->json['type'] = $keys[0]; |
| 271 | $values = array_values( $value ); |
| 272 | foreach ( $values[0][0] as $name => $val ) { |
| 273 | if ( $name !== 'path' ) { |
| 274 | $this->json[$name] = $val; |
| 275 | } |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | public function handleHooks( string $realName, array $value ) { |
| 280 | foreach ( $value as $hookName => &$handlers ) { |
| 281 | if ( $hookName === 'UnitTestsList' ) { |
| 282 | $this->output( "Note: the UnitTestsList hook is no longer necessary as " . |
| 283 | "long as your tests are located in the \"tests/phpunit/\" directory. " . |
| 284 | "Please see <https://www.mediawiki.org/wiki/Manual:PHP_unit_testing/" . |
| 285 | "Writing_unit_tests_for_extensions#Register_your_tests> for more details.\n" |
| 286 | ); |
| 287 | } |
| 288 | foreach ( $handlers as $func ) { |
| 289 | if ( $func instanceof Closure ) { |
| 290 | $this->fatalError( "Error: Closures cannot be converted to JSON. " . |
| 291 | "Please move the handler for $hookName somewhere else." |
| 292 | ); |
| 293 | } elseif ( function_exists( $func ) ) { |
| 294 | // Check if $func exists in the global scope |
| 295 | $this->fatalError( "Error: Global functions cannot be converted to JSON. " . |
| 296 | "Please move the handler for $hookName inside a class." |
| 297 | ); |
| 298 | } |
| 299 | } |
| 300 | if ( count( $handlers ) === 1 ) { |
| 301 | $handlers = $handlers[0]; |
| 302 | } |
| 303 | } |
| 304 | $this->json[$realName] = $value; |
| 305 | } |
| 306 | |
| 307 | /** |
| 308 | * @param string $realName |
| 309 | * @param array[] $value |
| 310 | */ |
| 311 | protected function handleResourceModules( $realName, $value ) { |
| 312 | $defaults = []; |
| 313 | $remote = $this->hasOption( 'skin' ) ? 'remoteSkinPath' : 'remoteExtPath'; |
| 314 | foreach ( $value as $name => $data ) { |
| 315 | if ( isset( $data['localBasePath'] ) ) { |
| 316 | $data['localBasePath'] = $this->stripPath( $data['localBasePath'], $this->dir ); |
| 317 | if ( !$defaults ) { |
| 318 | $defaults['localBasePath'] = $data['localBasePath']; |
| 319 | unset( $data['localBasePath'] ); |
| 320 | if ( isset( $data[$remote] ) ) { |
| 321 | $defaults[$remote] = $data[$remote]; |
| 322 | unset( $data[$remote] ); |
| 323 | } |
| 324 | } else { |
| 325 | if ( $data['localBasePath'] === $defaults['localBasePath'] ) { |
| 326 | unset( $data['localBasePath'] ); |
| 327 | } |
| 328 | if ( isset( $data[$remote] ) && isset( $defaults[$remote] ) |
| 329 | && $data[$remote] === $defaults[$remote] |
| 330 | ) { |
| 331 | unset( $data[$remote] ); |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | $this->json[$realName][$name] = $data; |
| 337 | } |
| 338 | if ( $defaults ) { |
| 339 | $this->json['ResourceFileModulePaths'] = $defaults; |
| 340 | } |
| 341 | } |
| 342 | |
| 343 | protected function needsComposerAutoloader( string $path ): bool { |
| 344 | $path .= '/composer.json'; |
| 345 | if ( file_exists( $path ) ) { |
| 346 | // assume that the composer.json file is in the root of the extension path |
| 347 | $composerJson = new ComposerJson( $path ); |
| 348 | // check if there are some dependencies in the require section |
| 349 | if ( $composerJson->getRequiredDependencies() ) { |
| 350 | return true; |
| 351 | } |
| 352 | } |
| 353 | return false; |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | // @codeCoverageIgnoreStart |
| 358 | $maintClass = ConvertExtensionToRegistration::class; |
| 359 | require_once RUN_MAINTENANCE_IF_MAIN; |
| 360 | // @codeCoverageIgnoreEnd |