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