1<?php 2/** 3 * Wikitext scripting infrastructure for MediaWiki: hooks. 4 * Copyright (C) 2009-2012 Victor Vasiliev <vasilvv@gmail.com> 5 * https://www.mediawiki.org/ 6 * 7 * This program is free software; you can redistribute it and/or modify 8 * it under the terms of the GNU General Public License as published by 9 * the Free Software Foundation; either version 2 of the License, or 10 * (at your option) any later version. 11 * 12 * This program is distributed in the hope that it will be useful, 13 * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 * GNU General Public License for more details. 16 * 17 * You should have received a copy of the GNU General Public License along 18 * with this program; if not, write to the Free Software Foundation, Inc., 19 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 20 * http://www.gnu.org/copyleft/gpl.html 21 */ 22 23use MediaWiki\MediaWikiServices; 24use UtfNormal\Validator; 25use Wikimedia\PSquare; 26 27/** 28 * Hooks for the Scribunto extension. 29 */ 30class ScribuntoHooks { 31 32 /** 33 * Define content handler constant upon extension registration 34 */ 35 public static function onRegistration() { 36 define( 'CONTENT_MODEL_SCRIBUNTO', 'Scribunto' ); 37 } 38 39 /** 40 * Get software information for Special:Version 41 * 42 * @param array &$software 43 * @return bool 44 */ 45 public static function getSoftwareInfo( array &$software ) { 46 $engine = Scribunto::newDefaultEngine(); 47 $engine->setTitle( Title::makeTitle( NS_SPECIAL, 'Version' ) ); 48 $engine->getSoftwareInfo( $software ); 49 return true; 50 } 51 52 /** 53 * Register parser hooks. 54 * 55 * @param Parser $parser 56 * @return bool 57 */ 58 public static function setupParserHook( Parser $parser ) { 59 $parser->setFunctionHook( 'invoke', 'ScribuntoHooks::invokeHook', Parser::SFH_OBJECT_ARGS ); 60 return true; 61 } 62 63 /** 64 * Called when the interpreter is to be reset. 65 * 66 * @param Parser $parser 67 * @return bool 68 */ 69 public static function clearState( Parser $parser ) { 70 Scribunto::resetParserEngine( $parser ); 71 return true; 72 } 73 74 /** 75 * Called when the parser is cloned 76 * 77 * @param Parser $parser 78 * @return bool 79 */ 80 public static function parserCloned( Parser $parser ) { 81 $parser->scribunto_engine = null; 82 return true; 83 } 84 85 /** 86 * Hook function for {{#invoke:module|func}} 87 * 88 * @param Parser $parser 89 * @param PPFrame $frame 90 * @param array $args 91 * @throws MWException 92 * @throws ScribuntoException 93 * @return string 94 */ 95 public static function invokeHook( Parser $parser, PPFrame $frame, array $args ) { 96 global $wgScribuntoGatherFunctionStats; 97 98 try { 99 if ( count( $args ) < 2 ) { 100 throw new ScribuntoException( 'scribunto-common-nofunction' ); 101 } 102 $moduleName = trim( $frame->expand( $args[0] ) ); 103 $engine = Scribunto::getParserEngine( $parser ); 104 105 $title = Title::makeTitleSafe( NS_MODULE, $moduleName ); 106 if ( !$title || !$title->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) { 107 throw new ScribuntoException( 'scribunto-common-nosuchmodule', 108 [ 'args' => [ $moduleName ] ] ); 109 } 110 $module = $engine->fetchModuleFromParser( $title ); 111 if ( !$module ) { 112 throw new ScribuntoException( 'scribunto-common-nosuchmodule', 113 [ 'args' => [ $moduleName ] ] ); 114 } 115 $functionName = trim( $frame->expand( $args[1] ) ); 116 117 $bits = $args[1]->splitArg(); 118 unset( $args[0] ); 119 unset( $args[1] ); 120 121 // If $bits['index'] is empty, then the function name was parsed as a 122 // key=value pair (because of an equals sign in it), and since it didn't 123 // have an index, we don't need the index offset. 124 $childFrame = $frame->newChild( $args, $title, $bits['index'] === '' ? 0 : 1 ); 125 126 if ( $wgScribuntoGatherFunctionStats ) { 127 $u0 = $engine->getResourceUsage( $engine::CPU_SECONDS ); 128 $result = $module->invoke( $functionName, $childFrame ); 129 $u1 = $engine->getResourceUsage( $engine::CPU_SECONDS ); 130 131 if ( $u1 > $u0 ) { 132 $timingMs = (int)( 1000 * ( $u1 - $u0 ) ); 133 // Since the overhead of stats is worst when when #invoke 134 // calls are very short, don't process measurements <= 20ms. 135 if ( $timingMs > 20 ) { 136 self::reportTiming( $moduleName, $functionName, $timingMs ); 137 } 138 } 139 } else { 140 $result = $module->invoke( $functionName, $childFrame ); 141 } 142 143 return Validator::cleanUp( strval( $result ) ); 144 } catch ( ScribuntoException $e ) { 145 $trace = $e->getScriptTraceHtml( [ 'msgOptions' => [ 'content' ] ] ); 146 $html = Html::element( 'p', [], $e->getMessage() ); 147 if ( $trace !== false ) { 148 $html .= Html::element( 'p', 149 [], 150 wfMessage( 'scribunto-common-backtrace' )->inContentLanguage()->text() 151 ) . $trace; 152 } else { 153 $html .= Html::element( 'p', 154 [], 155 wfMessage( 'scribunto-common-no-details' )->inContentLanguage()->text() 156 ); 157 } 158 $out = $parser->getOutput(); 159 $errors = $out->getExtensionData( 'ScribuntoErrors' ); 160 if ( $errors === null ) { 161 // On first hook use, set up error array and output 162 $errors = []; 163 $parser->addTrackingCategory( 'scribunto-common-error-category' ); 164 $out->addModules( 'ext.scribunto.errors' ); 165 } 166 $errors[] = $html; 167 $out->setExtensionData( 'ScribuntoErrors', $errors ); 168 $out->addJsConfigVars( 'ScribuntoErrors', $errors ); 169 $id = 'mw-scribunto-error-' . ( count( $errors ) - 1 ); 170 $parserError = htmlspecialchars( $e->getMessage() ); 171 172 // #iferror-compatible error element 173 return "<strong class=\"error\"><span class=\"scribunto-error\" id=\"$id\">" . 174 $parserError . "</span></strong>"; 175 } 176 } 177 178 /** 179 * Record stats on slow function calls. 180 * 181 * @param string $moduleName 182 * @param string $functionName 183 * @param int $timing Function execution time in milliseconds. 184 */ 185 public static function reportTiming( $moduleName, $functionName, $timing ) { 186 global $wgScribuntoGatherFunctionStats, $wgScribuntoSlowFunctionThreshold; 187 188 if ( !$wgScribuntoGatherFunctionStats ) { 189 return; 190 } 191 192 $threshold = $wgScribuntoSlowFunctionThreshold; 193 if ( !( is_float( $threshold ) && $threshold > 0 && $threshold < 1 ) ) { 194 return; 195 } 196 197 static $cache; 198 199 if ( !$cache ) { 200 $cache = ObjectCache::getLocalServerInstance( CACHE_NONE ); 201 202 } 203 204 // To control the sampling rate, we keep a compact histogram of 205 // observations in APC, and extract the Nth percentile (specified 206 // via $wgScribuntoSlowFunctionThreshold; defaults to 0.90). 207 // We need APC and \Wikimedia\PSquare to do that. 208 if ( !class_exists( PSquare::class ) || $cache instanceof EmptyBagOStuff ) { 209 return; 210 } 211 212 $cacheVersion = '1'; 213 $key = $cache->makeGlobalKey( __METHOD__, $cacheVersion, (string)$threshold ); 214 215 // This is a classic "read-update-write" critical section with no 216 // mutual exclusion, but the only consequence is that some samples 217 // will be dropped. We only need enough samples to estimate the 218 // the shape of the data, so that's fine. 219 $ps = $cache->get( $key ) ?: new PSquare( $threshold ); 220 $ps->addObservation( $timing ); 221 $cache->set( $key, $ps, 60 ); 222 223 if ( $ps->getCount() < 1000 || $timing < $ps->getValue() ) { 224 return; 225 } 226 227 static $stats; 228 229 if ( !$stats ) { 230 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); 231 } 232 233 $metricKey = sprintf( 'scribunto.traces.%s__%s__%s', wfWikiId(), $moduleName, $functionName ); 234 $stats->timing( $metricKey, $timing ); 235 } 236 237 /** 238 * @param Title $title 239 * @param string &$languageCode 240 * @return bool 241 */ 242 public static function getCodeLanguage( Title $title, &$languageCode ) { 243 global $wgScribuntoUseCodeEditor; 244 if ( $wgScribuntoUseCodeEditor && $title->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) 245 ) { 246 $engine = Scribunto::newDefaultEngine(); 247 if ( $engine->getCodeEditorLanguage() ) { 248 $languageCode = $engine->getCodeEditorLanguage(); 249 return false; 250 } 251 } 252 253 return true; 254 } 255 256 /** 257 * Set the Scribunto content handler for modules 258 * 259 * @param Title $title 260 * @param string &$model 261 * @return bool 262 */ 263 public static function contentHandlerDefaultModelFor( Title $title, &$model ) { 264 if ( $model === 'sanitized-css' ) { 265 // Let TemplateStyles override Scribunto 266 return true; 267 } 268 if ( $title->getNamespace() === NS_MODULE && !Scribunto::isDocPage( $title ) ) { 269 $model = CONTENT_MODEL_SCRIBUNTO; 270 return true; 271 } 272 return true; 273 } 274 275 /** 276 * Adds report of number of evaluations by the single wikitext page. 277 * 278 * @param Parser $parser 279 * @param ParserOutput $output 280 * @return bool 281 */ 282 public static function reportLimitData( Parser $parser, ParserOutput $output ) { 283 if ( Scribunto::isParserEnginePresent( $parser ) ) { 284 $engine = Scribunto::getParserEngine( $parser ); 285 $engine->reportLimitData( $output ); 286 } 287 return true; 288 } 289 290 /** 291 * Formats the limit report data 292 * 293 * @param string $key 294 * @param mixed &$value 295 * @param string &$report 296 * @param bool $isHTML 297 * @param bool $localize 298 * @return bool 299 */ 300 public static function formatLimitData( $key, &$value, &$report, $isHTML, $localize ) { 301 $engine = Scribunto::newDefaultEngine(); 302 return $engine->formatLimitData( $key, $value, $report, $isHTML, $localize ); 303 } 304 305 /** 306 * EditPage::showStandardInputs:options hook 307 * 308 * @param EditPage $editor 309 * @param OutputPage $output 310 * @param int &$tab Current tabindex 311 * @return bool 312 */ 313 public static function showStandardInputsOptions( EditPage $editor, OutputPage $output, &$tab ) { 314 if ( $editor->getTitle()->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) { 315 $output->addModules( 'ext.scribunto.edit' ); 316 $editor->editFormTextAfterTools .= '<div id="mw-scribunto-console"></div>'; 317 } 318 return true; 319 } 320 321 /** 322 * EditPage::showReadOnlyForm:initial hook 323 * 324 * @param EditPage $editor 325 * @param OutputPage $output 326 * @return bool 327 */ 328 public static function showReadOnlyFormInitial( EditPage $editor, OutputPage $output ) { 329 if ( $editor->getTitle()->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) { 330 $output->addModules( 'ext.scribunto.edit' ); 331 $editor->editFormTextAfterContent .= '<div id="mw-scribunto-console"></div>'; 332 } 333 return true; 334 } 335 336 /** 337 * EditPageBeforeEditButtons hook 338 * 339 * @param EditPage $editor 340 * @param array &$buttons Button array 341 * @param int &$tabindex Current tabindex 342 * @return bool 343 */ 344 public static function beforeEditButtons( EditPage $editor, array &$buttons, &$tabindex ) { 345 if ( $editor->getTitle()->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) { 346 unset( $buttons['preview'] ); 347 } 348 return true; 349 } 350 351 /** 352 * @param IContextSource $context 353 * @param Content $content 354 * @param Status $status 355 * @return bool 356 */ 357 public static function validateScript( IContextSource $context, Content $content, 358 Status $status 359 ) { 360 $title = $context->getTitle(); 361 362 if ( !$content instanceof ScribuntoContent ) { 363 return true; 364 } 365 366 $validateStatus = $content->validate( $title ); 367 if ( $validateStatus->isOK() ) { 368 return true; 369 } 370 371 $status->merge( $validateStatus ); 372 373 if ( isset( $validateStatus->scribunto_error->params['module'] ) ) { 374 $module = $validateStatus->scribunto_error->params['module']; 375 $line = $validateStatus->scribunto_error->params['line']; 376 if ( $module === $title->getPrefixedDBkey() && preg_match( '/^\d+$/', $line ) ) { 377 $out = $context->getOutput(); 378 $out->addInlineScript( 'window.location.hash = ' . Xml::encodeJsVar( "#mw-ce-l$line" ) ); 379 } 380 } 381 if ( !$status->isOK() ) { 382 // @todo Remove this line after this extension do not support mediawiki version 1.36 and before 383 $status->value = EditPage::AS_HOOK_ERROR_EXPECTED; 384 return false; 385 } 386 387 return true; 388 } 389 390 /** 391 * @param Article $article 392 * @param bool &$outputDone 393 * @param bool &$pcache 394 * @return bool 395 */ 396 public static function showDocPageHeader( Article $article, &$outputDone, &$pcache ) { 397 $title = $article->getTitle(); 398 if ( Scribunto::isDocPage( $title, $forModule ) ) { 399 $article->getContext()->getOutput()->addHTML( 400 wfMessage( 'scribunto-doc-page-header', $forModule->getPrefixedText() )->parseAsBlock() 401 ); 402 } 403 return true; 404 } 405} 406