mediawiki-extensions-Scribunto/includes/Hooks.php
C. Scott Ananian 248e886dc0 Use class instead of id for scribunto errors
This avoids having multiple HTML elements with the same `id` attribute
when certain Scribunto errors reoccur more than once on the same page.

Bug: T375539
Change-Id: I123a0f0046a616f63506f096651ae917bbc65cc5
2024-09-24 15:47:25 -04:00

442 lines
13 KiB
PHP

<?php
/**
* Wikitext scripting infrastructure for MediaWiki: hooks.
* Copyright (C) 2009-2012 Victor Vasiliev <vasilvv@gmail.com>
* https://www.mediawiki.org/
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*/
// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
namespace MediaWiki\Extension\Scribunto;
use Article;
use Content;
use EmptyBagOStuff;
use MediaWiki\Config\Config;
use MediaWiki\Context\IContextSource;
use MediaWiki\EditPage\EditPage;
use MediaWiki\Hook\EditFilterMergedContentHook;
use MediaWiki\Hook\EditPage__showReadOnlyForm_initialHook;
use MediaWiki\Hook\EditPage__showStandardInputs_optionsHook;
use MediaWiki\Hook\EditPageBeforeEditButtonsHook;
use MediaWiki\Hook\ParserClearStateHook;
use MediaWiki\Hook\ParserClonedHook;
use MediaWiki\Hook\ParserFirstCallInitHook;
use MediaWiki\Hook\ParserLimitReportFormatHook;
use MediaWiki\Hook\ParserLimitReportPrepareHook;
use MediaWiki\Hook\SoftwareInfoHook;
use MediaWiki\Html\Html;
use MediaWiki\MediaWikiServices;
use MediaWiki\Output\OutputPage;
use MediaWiki\Page\Hook\ArticleViewHeaderHook;
use MediaWiki\Parser\Parser;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Revision\Hook\ContentHandlerDefaultModelForHook;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\WikiMap\WikiMap;
use PPFrame;
use PPNode;
use UtfNormal\Validator;
use Wikimedia\PSquare;
/**
* Hooks for the Scribunto extension.
*/
class Hooks implements
SoftwareInfoHook,
ParserFirstCallInitHook,
ParserLimitReportPrepareHook,
ParserLimitReportFormatHook,
ParserClearStateHook,
ParserClonedHook,
EditPage__showStandardInputs_optionsHook,
EditPage__showReadOnlyForm_initialHook,
EditPageBeforeEditButtonsHook,
EditFilterMergedContentHook,
ArticleViewHeaderHook,
ContentHandlerDefaultModelForHook
{
private Config $config;
public function __construct(
Config $config
) {
$this->config = $config;
}
/**
* Define content handler constant upon extension registration
*/
public static function onRegistration() {
define( 'CONTENT_MODEL_SCRIBUNTO', 'Scribunto' );
}
/**
* Get software information for Special:Version
*
* @param array &$software
* @return bool
*/
public function onSoftwareInfo( &$software ) {
$engine = Scribunto::newDefaultEngine();
$engine->setTitle( Title::makeTitle( NS_SPECIAL, 'Version' ) );
$engine->getSoftwareInfo( $software );
return true;
}
/**
* Register parser hooks.
*
* @param Parser $parser
* @return void
*/
public function onParserFirstCallInit( $parser ) {
$parser->setFunctionHook( 'invoke', [ $this, 'invokeHook' ], Parser::SFH_OBJECT_ARGS );
}
/**
* Called when the interpreter is to be reset.
*
* @param Parser $parser
* @return void
*/
public function onParserClearState( $parser ) {
Scribunto::resetParserEngine( $parser );
}
/**
* Called when the parser is cloned
*
* @param Parser $parser
* @return void
*/
public function onParserCloned( $parser ) {
$parser->scribunto_engine = null;
}
/**
* Hook function for {{#invoke:module|func}}
*
* @param Parser $parser
* @param PPFrame $frame
* @param PPNode[] $args
* @return string
*/
public function invokeHook( Parser $parser, PPFrame $frame, array $args ) {
try {
if ( count( $args ) < 2 ) {
throw new ScribuntoException( 'scribunto-common-nofunction' );
}
$moduleName = trim( $frame->expand( $args[0] ) );
$engine = Scribunto::getParserEngine( $parser );
$title = Title::makeTitleSafe( NS_MODULE, $moduleName );
if ( !$title || !$title->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) {
throw new ScribuntoException( 'scribunto-common-nosuchmodule',
[ 'args' => [ $moduleName ] ] );
}
$module = $engine->fetchModuleFromParser( $title );
if ( !$module ) {
throw new ScribuntoException( 'scribunto-common-nosuchmodule',
[ 'args' => [ $moduleName ] ] );
}
$functionName = trim( $frame->expand( $args[1] ) );
$bits = $args[1]->splitArg();
unset( $args[0] );
unset( $args[1] );
// If $bits['index'] is empty, then the function name was parsed as a
// key=value pair (because of an equals sign in it), and since it didn't
// have an index, we don't need the index offset.
$childFrame = $frame->newChild( $args, $title, $bits['index'] === '' ? 0 : 1 );
if ( $this->config->get( 'ScribuntoGatherFunctionStats' ) ) {
$u0 = $engine->getResourceUsage( $engine::CPU_SECONDS );
$result = $module->invoke( $functionName, $childFrame );
$u1 = $engine->getResourceUsage( $engine::CPU_SECONDS );
if ( $u1 > $u0 ) {
$timingMs = (int)( 1000 * ( $u1 - $u0 ) );
// Since the overhead of stats is worst when #invoke
// calls are very short, don't process measurements <= 20ms.
if ( $timingMs > 20 ) {
$this->reportTiming( $moduleName, $functionName, $timingMs );
}
}
} else {
$result = $module->invoke( $functionName, $childFrame );
}
return Validator::cleanUp( strval( $result ) );
} catch ( ScribuntoException $e ) {
$trace = $e->getScriptTraceHtml( [ 'msgOptions' => [ 'content' ] ] );
$html = Html::element( 'p', [], $e->getMessage() );
if ( $trace !== false ) {
$html .= Html::element( 'p',
[],
wfMessage( 'scribunto-common-backtrace' )->inContentLanguage()->text()
) . $trace;
} else {
$html .= Html::element( 'p',
[],
wfMessage( 'scribunto-common-no-details' )->inContentLanguage()->text()
);
}
// Index this error by a uniq ID so that we are independent of
// page parse order. (T300979)
// (The only way this will conflict is if two exceptions have
// exactly the same backtrace, in which case we really only need
// one copy of the backtrace!)
$uuid = substr( sha1( $html ), -8 );
$parserOutput = $parser->getOutput();
$parserOutput->appendExtensionData( 'ScribuntoErrors', $uuid );
$parserOutput->setExtensionData( "ScribuntoErrors-$uuid", $html );
$parserOutput->appendJsConfigVar( 'ScribuntoErrors', $uuid );
$parserOutput->setJsConfigVar( "ScribuntoErrors-$uuid", $html );
// These methods are idempotent; doesn't hurt to call them every
// time.
$parser->addTrackingCategory( 'scribunto-common-error-category' );
$parserOutput->addModules( [ 'ext.scribunto.errors' ] );
$id = "mw-scribunto-error-$uuid";
$parserError = htmlspecialchars( $e->getMessage() );
// #iferror-compatible error element
return "<strong class=\"error\"><span class=\"scribunto-error $id\">" .
$parserError . "</span></strong>";
}
}
/**
* Record stats on slow function calls.
*
* @param string $moduleName
* @param string $functionName
* @param int $timing Function execution time in milliseconds.
*/
private function reportTiming( $moduleName, $functionName, $timing ) {
if ( !$this->config->get( 'ScribuntoGatherFunctionStats' ) ) {
return;
}
$threshold = $this->config->get( 'ScribuntoSlowFunctionThreshold' );
if ( !( is_float( $threshold ) && $threshold > 0 && $threshold < 1 ) ) {
return;
}
$objectcachefactory = MediaWikiServices::getInstance()->getObjectCacheFactory();
static $cache;
if ( !$cache ) {
$cache = $objectcachefactory->getLocalServerInstance( CACHE_NONE );
}
// To control the sampling rate, we keep a compact histogram of
// observations in APC, and extract the Nth percentile (specified
// via $wgScribuntoSlowFunctionThreshold; defaults to 0.90).
// We need a non-empty local server cache for that (e.g. php-apcu).
if ( $cache instanceof EmptyBagOStuff ) {
return;
}
$cacheVersion = '3';
$key = $cache->makeGlobalKey( 'scribunto-stats', $cacheVersion, (string)$threshold );
// This is a classic "read-update-write" critical section with no
// mutual exclusion, but the only consequence is that some samples
// will be dropped. We only need enough samples to estimate the
// shape of the data, so that's fine.
$ps = $cache->get( $key ) ?: new PSquare( $threshold );
$ps->addObservation( $timing );
$cache->set( $key, $ps, 60 );
if ( $ps->getCount() < 1000 || $timing < $ps->getValue() ) {
return;
}
static $stats;
if ( !$stats ) {
$stats = MediaWikiServices::getInstance()->getStatsFactory();
}
$statAction = WikiMap::getCurrentWikiId() . '__' . $moduleName . '__' . $functionName;
$stats->getTiming( 'scribunto_traces_seconds' )
->setLabel( 'action', $statAction )
->copyToStatsdAt( 'scribunto.traces.' . $statAction )
->observe( $timing );
}
/**
* Set the Scribunto content handler for modules
*
* @param Title $title
* @param string &$model
* @return void
*/
public function onContentHandlerDefaultModelFor( $title, &$model ) {
if ( $model === 'sanitized-css' ) {
// Let TemplateStyles override Scribunto
return;
}
if ( $title->getNamespace() === NS_MODULE ) {
if ( str_ends_with( $title->getText(), '.json' ) ) {
$model = CONTENT_MODEL_JSON;
} elseif ( !Scribunto::isDocPage( $title ) ) {
$model = CONTENT_MODEL_SCRIBUNTO;
}
}
}
/**
* Adds report of number of evaluations by the single wikitext page.
*
* @param Parser $parser
* @param ParserOutput $parserOutput
* @return void
*/
public function onParserLimitReportPrepare( $parser, $parserOutput ) {
if ( Scribunto::isParserEnginePresent( $parser ) ) {
$engine = Scribunto::getParserEngine( $parser );
$engine->reportLimitData( $parserOutput );
}
}
/**
* Formats the limit report data
*
* @param string $key
* @param mixed &$value
* @param string &$report
* @param bool $isHTML
* @param bool $localize
* @return bool
*/
public function onParserLimitReportFormat( $key, &$value, &$report, $isHTML, $localize ) {
$engine = Scribunto::newDefaultEngine();
return $engine->formatLimitData( $key, $value, $report, $isHTML, $localize );
}
/**
* EditPage::showStandardInputs:options hook
*
* @param EditPage $editor
* @param OutputPage $output
* @param int &$tab Current tabindex
* @return void
*/
public function onEditPage__showStandardInputs_options( $editor, $output, &$tab ) {
if ( $editor->getTitle()->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) {
$output->addModules( 'ext.scribunto.edit' );
$editor->editFormTextAfterTools .= '<div id="mw-scribunto-console"></div>';
}
}
/**
* EditPage::showReadOnlyForm:initial hook
*
* @param EditPage $editor
* @param OutputPage $output
* @return void
*/
public function onEditPage__showReadOnlyForm_initial( $editor, $output ) {
if ( $editor->getTitle()->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) {
$output->addModules( 'ext.scribunto.edit' );
$editor->editFormTextAfterContent .= '<div id="mw-scribunto-console"></div>';
}
}
/**
* EditPageBeforeEditButtons hook
*
* @param EditPage $editor
* @param array &$buttons Button array
* @param int &$tabindex Current tabindex
* @return void
*/
public function onEditPageBeforeEditButtons( $editor, &$buttons, &$tabindex ) {
if ( $editor->getTitle()->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) {
unset( $buttons['preview'] );
}
}
/**
* @param IContextSource $context
* @param Content $content
* @param Status $status
* @param string $summary
* @param User $user
* @param bool $minoredit
* @return bool
*/
public function onEditFilterMergedContent( IContextSource $context, Content $content,
Status $status, $summary, User $user, $minoredit
) {
$title = $context->getTitle();
if ( !$content instanceof ScribuntoContent ) {
return true;
}
$contentHandlerFactory = MediaWikiServices::getInstance()->getContentHandlerFactory();
$contentHandler = $contentHandlerFactory->getContentHandler( $content->getModel() );
'@phan-var ScribuntoContentHandler $contentHandler';
$validateStatus = $contentHandler->validate( $content, $title );
if ( $validateStatus->isOK() ) {
return true;
}
$status->merge( $validateStatus );
if ( isset( $validateStatus->value->params['module'] ) ) {
$module = $validateStatus->value->params['module'];
$line = $validateStatus->value->params['line'];
if ( $module === $title->getPrefixedDBkey() && preg_match( '/^\d+$/', $line ) ) {
$out = $context->getOutput();
$out->addInlineScript( 'window.location.hash = ' . Html::encodeJsVar( "#mw-ce-l$line" ) );
}
}
if ( !$status->isOK() ) {
// @todo Remove this line after this extension do not support mediawiki version 1.36 and before
$status->value = EditPage::AS_HOOK_ERROR_EXPECTED;
return false;
}
return true;
}
/**
* @param Article $article
* @param bool|ParserOutput|null &$outputDone
* @param bool &$pcache
* @return void
*/
public function onArticleViewHeader( $article, &$outputDone, &$pcache ) {
$title = $article->getTitle();
if ( Scribunto::isDocPage( $title, $forModule ) ) {
$article->getContext()->getOutput()->addHTML(
wfMessage( 'scribunto-doc-page-header', $forModule->getPrefixedText() )->parseAsBlock()
);
}
}
}