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\Registration\ExtensionProcessor; |
5 | use MediaWiki\Registration\ExtensionRegistry; |
6 | use Wikimedia\Composer\ComposerJson; |
7 | |
8 | // @codeCoverageIgnoreStart |
9 | require_once __DIR__ . '/Maintenance.php'; |
10 | // @codeCoverageIgnoreEnd |
11 | |
12 | class ConvertExtensionToRegistration extends Maintenance { |
13 | |
14 | private const CUSTOM_GLOBALS = [ |
15 | 'MessagesDirs' => 'handleMessagesDirs', |
16 | 'ExtensionMessagesFiles' => 'handleExtensionMessagesFiles', |
17 | 'AutoloadClasses' => 'removeAbsolutePath', |
18 | 'ExtensionCredits' => 'handleCredits', |
19 | 'ResourceModules' => 'handleResourceModules', |
20 | 'ResourceModuleSkinStyles' => 'handleResourceModules', |
21 | 'Hooks' => 'handleHooks', |
22 | 'ExtensionFunctions' => 'handleExtensionFunctions', |
23 | 'ParserTestFiles' => 'removeAutodiscoveredParserTestFiles', |
24 | ]; |
25 | |
26 | /** |
27 | * Things that were formerly globals and should still be converted |
28 | */ |
29 | private const FORMER_GLOBALS = [ |
30 | 'TrackingCategories', |
31 | ]; |
32 | |
33 | /** |
34 | * No longer supported globals (with reason) should not be converted and emit a warning |
35 | */ |
36 | private const NO_LONGER_SUPPORTED_GLOBALS = [ |
37 | 'SpecialPageGroups' => 'deprecated', // Deprecated 1.21, removed in 1.26 |
38 | ]; |
39 | |
40 | /** |
41 | * Keys that should be put at the top of the generated JSON file (T86608) |
42 | */ |
43 | private const PROMOTE_ATTRIBUTES = [ |
44 | 'name', |
45 | 'namemsg', |
46 | 'version', |
47 | 'author', |
48 | 'url', |
49 | 'description', |
50 | 'descriptionmsg', |
51 | 'license-name', |
52 | 'type', |
53 | ]; |
54 | |
55 | private array $json; |
56 | private string $dir; |
57 | private bool $hasWarning = false; |
58 | |
59 | public function __construct() { |
60 | parent::__construct(); |
61 | $this->addDescription( 'Converts extension entry points to the new JSON registration format' ); |
62 | $this->addArg( 'path', 'Location to the PHP entry point you wish to convert', |
63 | /* $required = */ true ); |
64 | $this->addOption( 'skin', 'Whether to write to skin.json', false, false ); |
65 | $this->addOption( 'config-prefix', 'Custom prefix for configuration settings', false, true ); |
66 | } |
67 | |
68 | protected function getAllGlobals() { |
69 | $processor = new ReflectionClass( ExtensionProcessor::class ); |
70 | $settings = $processor->getProperty( 'globalSettings' ); |
71 | $settings->setAccessible( true ); |
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 === false ) { |
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 | call_user_func_array( [ $this, self::CUSTOM_GLOBALS[$realName] ], |
117 | [ $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 ( strpos( $name, $configPrefix ) === 0 ) { |
126 | $configName = substr( $name, strlen( $configPrefix ) ); |
127 | |
128 | $isPath = false; |
129 | if ( is_array( $value ) ) { |
130 | foreach ( $value as $k => $v ) { |
131 | if ( strpos( $v, $this->dir ) !== false ) { |
132 | $value[$k] = $this->stripPath( $v, $this->dir ); |
133 | $isPath = true; |
134 | } |
135 | } |
136 | } elseif ( is_string( $value ) && strpos( $value, $this->dir ) !== false ) { |
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' && strpos( $name, 'wg' ) === 0 ) { |
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( $realName, $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( $realName, $value ) { |
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( $realName, $value, $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( $val, $dir ) { |
229 | if ( $val === $dir ) { |
230 | $val = ''; |
231 | } elseif ( strpos( $val, $dir ) === 0 ) { |
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( $realName, $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( $realName, $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( $realName, $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( $realName, $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( $path ) { |
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 |