Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 171 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
ConvertExtensionToRegistration | |
0.00% |
0 / 171 |
|
0.00% |
0 / 13 |
5256 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
getAllGlobals | |
0.00% |
0 / 4 |
|
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() { |
70 | $processor = new ReflectionClass( ExtensionProcessor::class ); |
71 | $settings = $processor->getProperty( 'globalSettings' ); |
72 | $settings->setAccessible( true ); |
73 | return array_merge( $settings->getValue(), self::FORMER_GLOBALS ); |
74 | } |
75 | |
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 ) { |
83 | $var = 'wg' . $var; |
84 | $$var = []; |
85 | } |
86 | unset( $var ); |
87 | $arg = $this->getArg( 0 ); |
88 | if ( !is_file( $arg ) ) { |
89 | $this->fatalError( "$arg is not a file." ); |
90 | } |
91 | require $arg; |
92 | unset( $arg ); |
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 ) ) ); |
98 | $this->json = []; |
99 | $globalSettings = $this->getAllGlobals(); |
100 | $configPrefix = $this->getOption( 'config-prefix', 'wg' ); |
101 | if ( $configPrefix !== 'wg' ) { |
102 | $this->json['config']['_prefix'] = $configPrefix; |
103 | } |
104 | |
105 | foreach ( $vars as $name => $value ) { |
106 | $realName = substr( $name, 2 ); // Strip 'wg' |
107 | if ( $realName === false ) { |
108 | continue; |
109 | } |
110 | |
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 ) ) { |
113 | continue; |
114 | } |
115 | |
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 ) ); |
128 | |
129 | $isPath = false; |
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 ); |
134 | $isPath = true; |
135 | } |
136 | } |
137 | } elseif ( is_string( $value ) && strpos( $value, $this->dir ) !== false ) { |
138 | $value = $this->stripPath( $value, $this->dir ); |
139 | $isPath = true; |
140 | } |
141 | |
142 | // Most likely a config setting |
143 | $this->json['config'][$configName] = [ 'value' => $value ]; |
144 | |
145 | if ( $isPath ) { |
146 | $this->json['config'][$configName]['path'] = true; |
147 | } |
148 | } elseif ( $configPrefix !== 'wg' && strpos( $name, 'wg' ) === 0 ) { |
149 | // Warn about this |
150 | $this->output( 'Warning: Skipped global "' . $name . '" (' . |
151 | 'config prefix is "' . $configPrefix . '"). ' . |
152 | "Please check that this setting isn't needed.\n" ); |
153 | } |
154 | } |
155 | |
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; |
161 | } |
162 | |
163 | // Move some keys to the top |
164 | $out = []; |
165 | foreach ( self::PROMOTE_ATTRIBUTES as $key ) { |
166 | if ( isset( $this->json[$key] ) ) { |
167 | $out[$key] = $this->json[$key]; |
168 | unset( $this->json[$key] ); |
169 | } |
170 | } |
171 | // Set a requirement on the MediaWiki version that the current MANIFEST_VERSION |
172 | // was introduced in. |
173 | $out['requires'] = [ |
174 | ExtensionRegistry::MEDIAWIKI_CORE => ExtensionRegistry::MANIFEST_VERSION_MW_VERSION |
175 | ]; |
176 | $out += $this->json; |
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" ); |
186 | } |
187 | } |
188 | |
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." |
194 | ); |
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." |
199 | ); |
200 | } |
201 | } |
202 | |
203 | $this->json[$realName] = $value; |
204 | } |
205 | |
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 ); |
210 | } |
211 | } |
212 | } |
213 | |
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] ) ) { |
218 | $this->output( |
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" |
222 | ); |
223 | } else { |
224 | $this->json[$realName][$key] = $strippedFile; |
225 | } |
226 | } |
227 | } |
228 | |
229 | private function stripPath( $val, $dir ) { |
230 | if ( $val === $dir ) { |
231 | $val = ''; |
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 ); |
235 | } |
236 | |
237 | return $val; |
238 | } |
239 | |
240 | protected function removeAbsolutePath( $realName, $value ) { |
241 | $out = []; |
242 | foreach ( $value as $key => $val ) { |
243 | $out[$key] = $this->stripPath( $val, $this->dir ); |
244 | } |
245 | $this->json[$realName] = $out; |
246 | } |
247 | |
248 | protected function removeAutodiscoveredParserTestFiles( $realName, $value ) { |
249 | $out = []; |
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' ) ) { |
255 | $out[$key] = $path; |
256 | } |
257 | } |
258 | // in the best case all entries are filtered out |
259 | if ( $out ) { |
260 | $this->json[$realName] = $out; |
261 | } |
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. |
267 | } |
268 | |
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; |
276 | } |
277 | } |
278 | } |
279 | |
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" |
287 | ); |
288 | } |
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." |
293 | ); |
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." |
298 | ); |
299 | } |
300 | } |
301 | if ( count( $handlers ) === 1 ) { |
302 | $handlers = $handlers[0]; |
303 | } |
304 | } |
305 | $this->json[$realName] = $value; |
306 | } |
307 | |
308 | /** |
309 | * @param string $realName |
310 | * @param array[] $value |
311 | */ |
312 | protected function handleResourceModules( $realName, $value ) { |
313 | $defaults = []; |
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 ); |
318 | if ( !$defaults ) { |
319 | $defaults['localBasePath'] = $data['localBasePath']; |
320 | unset( $data['localBasePath'] ); |
321 | if ( isset( $data[$remote] ) ) { |
322 | $defaults[$remote] = $data[$remote]; |
323 | unset( $data[$remote] ); |
324 | } |
325 | } else { |
326 | if ( $data['localBasePath'] === $defaults['localBasePath'] ) { |
327 | unset( $data['localBasePath'] ); |
328 | } |
329 | if ( isset( $data[$remote] ) && isset( $defaults[$remote] ) |
330 | && $data[$remote] === $defaults[$remote] |
331 | ) { |
332 | unset( $data[$remote] ); |
333 | } |
334 | } |
335 | } |
336 | |
337 | $this->json[$realName][$name] = $data; |
338 | } |
339 | if ( $defaults ) { |
340 | $this->json['ResourceFileModulePaths'] = $defaults; |
341 | } |
342 | } |
343 | |
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() ) { |
351 | return true; |
352 | } |
353 | } |
354 | return false; |
355 | } |
356 | } |
357 | |
358 | // @codeCoverageIgnoreStart |
359 | $maintClass = ConvertExtensionToRegistration::class; |
360 | require_once RUN_MAINTENANCE_IF_MAIN; |
361 | // @codeCoverageIgnoreEnd |