Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
7.35% covered (danger)
7.35%
10 / 136
12.50% covered (danger)
12.50%
2 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
7.35% covered (danger)
7.35%
10 / 136
12.50% covered (danger)
12.50%
2 / 16
2119.40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onRegistration
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onSoftwareInfo
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 onParserFirstCallInit
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onParserClearState
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onParserCloned
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 invokeHook
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
132
 reportTiming
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
132
 onContentHandlerDefaultModelFor
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 onParserLimitReportPrepare
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onParserLimitReportFormat
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onEditPage__showStandardInputs_options
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onEditPage__showReadOnlyForm_initial
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onEditPageBeforeEditButtons
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onEditFilterMergedContent
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 onArticleViewHeader
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
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
23// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
24
25namespace MediaWiki\Extension\Scribunto;
26
27use Article;
28use Content;
29use EmptyBagOStuff;
30use IContextSource;
31use MediaWiki\Config\Config;
32use MediaWiki\EditPage\EditPage;
33use MediaWiki\Hook\EditFilterMergedContentHook;
34use MediaWiki\Hook\EditPage__showReadOnlyForm_initialHook;
35use MediaWiki\Hook\EditPage__showStandardInputs_optionsHook;
36use MediaWiki\Hook\EditPageBeforeEditButtonsHook;
37use MediaWiki\Hook\ParserClearStateHook;
38use MediaWiki\Hook\ParserClonedHook;
39use MediaWiki\Hook\ParserFirstCallInitHook;
40use MediaWiki\Hook\ParserLimitReportFormatHook;
41use MediaWiki\Hook\ParserLimitReportPrepareHook;
42use MediaWiki\Hook\SoftwareInfoHook;
43use MediaWiki\Html\Html;
44use MediaWiki\MediaWikiServices;
45use MediaWiki\Output\OutputPage;
46use MediaWiki\Page\Hook\ArticleViewHeaderHook;
47use MediaWiki\Parser\ParserOutput;
48use MediaWiki\Revision\Hook\ContentHandlerDefaultModelForHook;
49use MediaWiki\Status\Status;
50use MediaWiki\Title\Title;
51use MediaWiki\User\User;
52use MediaWiki\WikiMap\WikiMap;
53use ObjectCache;
54use Parser;
55use PPFrame;
56use UtfNormal\Validator;
57use Wikimedia\PSquare;
58use Xml;
59
60/**
61 * Hooks for the Scribunto extension.
62 */
63class Hooks implements
64    SoftwareInfoHook,
65    ParserFirstCallInitHook,
66    ParserLimitReportPrepareHook,
67    ParserLimitReportFormatHook,
68    ParserClearStateHook,
69    ParserClonedHook,
70    EditPage__showStandardInputs_optionsHook,
71    EditPage__showReadOnlyForm_initialHook,
72    EditPageBeforeEditButtonsHook,
73    EditFilterMergedContentHook,
74    ArticleViewHeaderHook,
75    ContentHandlerDefaultModelForHook
76{
77    private Config $config;
78
79    public function __construct(
80        Config $config
81    ) {
82        $this->config = $config;
83    }
84
85    /**
86     * Define content handler constant upon extension registration
87     */
88    public static function onRegistration() {
89        define( 'CONTENT_MODEL_SCRIBUNTO', 'Scribunto' );
90    }
91
92    /**
93     * Get software information for Special:Version
94     *
95     * @param array &$software
96     * @return bool
97     */
98    public function onSoftwareInfo( &$software ) {
99        $engine = Scribunto::newDefaultEngine();
100        $engine->setTitle( Title::makeTitle( NS_SPECIAL, 'Version' ) );
101        $engine->getSoftwareInfo( $software );
102        return true;
103    }
104
105    /**
106     * Register parser hooks.
107     *
108     * @param Parser $parser
109     * @return bool
110     */
111    public function onParserFirstCallInit( $parser ) {
112        $parser->setFunctionHook( 'invoke', [ $this, 'invokeHook' ], Parser::SFH_OBJECT_ARGS );
113        return true;
114    }
115
116    /**
117     * Called when the interpreter is to be reset.
118     *
119     * @param Parser $parser
120     * @return bool
121     */
122    public function onParserClearState( $parser ) {
123        Scribunto::resetParserEngine( $parser );
124        return true;
125    }
126
127    /**
128     * Called when the parser is cloned
129     *
130     * @param Parser $parser
131     * @return bool
132     */
133    public function onParserCloned( $parser ) {
134        $parser->scribunto_engine = null;
135        return true;
136    }
137
138    /**
139     * Hook function for {{#invoke:module|func}}
140     *
141     * @param Parser $parser
142     * @param PPFrame $frame
143     * @param array $args
144     * @return string
145     */
146    public function invokeHook( Parser $parser, PPFrame $frame, array $args ) {
147        try {
148            if ( count( $args ) < 2 ) {
149                throw new ScribuntoException( 'scribunto-common-nofunction' );
150            }
151            $moduleName = trim( $frame->expand( $args[0] ) );
152            $engine = Scribunto::getParserEngine( $parser );
153
154            $title = Title::makeTitleSafe( NS_MODULE, $moduleName );
155            if ( !$title || !$title->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) {
156                throw new ScribuntoException( 'scribunto-common-nosuchmodule',
157                    [ 'args' => [ $moduleName ] ] );
158            }
159            $module = $engine->fetchModuleFromParser( $title );
160            if ( !$module ) {
161                throw new ScribuntoException( 'scribunto-common-nosuchmodule',
162                    [ 'args' => [ $moduleName ] ] );
163            }
164            $functionName = trim( $frame->expand( $args[1] ) );
165
166            $bits = $args[1]->splitArg();
167            unset( $args[0] );
168            unset( $args[1] );
169
170            // If $bits['index'] is empty, then the function name was parsed as a
171            // key=value pair (because of an equals sign in it), and since it didn't
172            // have an index, we don't need the index offset.
173            $childFrame = $frame->newChild( $args, $title, $bits['index'] === '' ? 0 : 1 );
174
175            if ( $this->config->get( 'ScribuntoGatherFunctionStats' ) ) {
176                $u0 = $engine->getResourceUsage( $engine::CPU_SECONDS );
177                $result = $module->invoke( $functionName, $childFrame );
178                $u1 = $engine->getResourceUsage( $engine::CPU_SECONDS );
179
180                if ( $u1 > $u0 ) {
181                    $timingMs = (int)( 1000 * ( $u1 - $u0 ) );
182                    // Since the overhead of stats is worst when #invoke
183                    // calls are very short, don't process measurements <= 20ms.
184                    if ( $timingMs > 20 ) {
185                        $this->reportTiming( $moduleName, $functionName, $timingMs );
186                    }
187                }
188            } else {
189                $result = $module->invoke( $functionName, $childFrame );
190            }
191
192            return Validator::cleanUp( strval( $result ) );
193        } catch ( ScribuntoException $e ) {
194            $trace = $e->getScriptTraceHtml( [ 'msgOptions' => [ 'content' ] ] );
195            $html = Html::element( 'p', [], $e->getMessage() );
196            if ( $trace !== false ) {
197                $html .= Html::element( 'p',
198                    [],
199                    wfMessage( 'scribunto-common-backtrace' )->inContentLanguage()->text()
200                ) . $trace;
201            } else {
202                $html .= Html::element( 'p',
203                    [],
204                    wfMessage( 'scribunto-common-no-details' )->inContentLanguage()->text()
205                );
206            }
207
208            // Index this error by a uniq ID so that we are independent of
209            // page parse order. (T300979)
210            // (The only way this will conflict is if two exceptions have
211            // exactly the same backtrace, in which case we really only need
212            // one copy of the backtrace!)
213            $uuid = substr( sha1( $html ), -8 );
214            $parserOutput = $parser->getOutput();
215            $parserOutput->appendExtensionData( 'ScribuntoErrors', $uuid );
216            $parserOutput->setExtensionData( "ScribuntoErrors-$uuid", $html );
217
218            $parserOutput->appendJsConfigVar( 'ScribuntoErrors', $uuid );
219            $parserOutput->setJsConfigVar( "ScribuntoErrors-$uuid", $html );
220
221            // These methods are idempotent; doesn't hurt to call them every
222            // time.
223            $parser->addTrackingCategory( 'scribunto-common-error-category' );
224            $parserOutput->addModules( [ 'ext.scribunto.errors' ] );
225
226            $id = "mw-scribunto-error-$uuid";
227            $parserError = htmlspecialchars( $e->getMessage() );
228
229            // #iferror-compatible error element
230            return "<strong class=\"error\"><span class=\"scribunto-error\" id=\"$id\">" .
231                $parserError . "</span></strong>";
232        }
233    }
234
235    /**
236     * Record stats on slow function calls.
237     *
238     * @param string $moduleName
239     * @param string $functionName
240     * @param int $timing Function execution time in milliseconds.
241     */
242    private function reportTiming( $moduleName, $functionName, $timing ) {
243        if ( !$this->config->get( 'ScribuntoGatherFunctionStats' ) ) {
244            return;
245        }
246
247        $threshold = $this->config->get( 'ScribuntoSlowFunctionThreshold' );
248        if ( !( is_float( $threshold ) && $threshold > 0 && $threshold < 1 ) ) {
249            return;
250        }
251
252        static $cache;
253        if ( !$cache ) {
254            $cache = ObjectCache::getLocalServerInstance( CACHE_NONE );
255
256        }
257
258        // To control the sampling rate, we keep a compact histogram of
259        // observations in APC, and extract the Nth percentile (specified
260        // via $wgScribuntoSlowFunctionThreshold; defaults to 0.90).
261        // We need a non-empty local server cache for that (e.g. php-apcu).
262        if ( $cache instanceof EmptyBagOStuff ) {
263            return;
264        }
265
266        $cacheVersion = '3';
267        $key = $cache->makeGlobalKey( 'scribunto-stats', $cacheVersion, (string)$threshold );
268
269        // This is a classic "read-update-write" critical section with no
270        // mutual exclusion, but the only consequence is that some samples
271        // will be dropped. We only need enough samples to estimate the
272        // shape of the data, so that's fine.
273        $ps = $cache->get( $key ) ?: new PSquare( $threshold );
274        $ps->addObservation( $timing );
275        $cache->set( $key, $ps, 60 );
276
277        if ( $ps->getCount() < 1000 || $timing < $ps->getValue() ) {
278            return;
279        }
280
281        static $stats;
282        if ( !$stats ) {
283            $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
284        }
285
286        $metricKey = sprintf( 'scribunto.traces.%s__%s__%s', WikiMap::getCurrentWikiId(), $moduleName, $functionName );
287        $stats->timing( $metricKey, $timing );
288    }
289
290    /**
291     * Set the Scribunto content handler for modules
292     *
293     * @param Title $title
294     * @param string &$model
295     * @return bool
296     */
297    public function onContentHandlerDefaultModelFor( $title, &$model ) {
298        if ( $model === 'sanitized-css' ) {
299            // Let TemplateStyles override Scribunto
300            return true;
301        }
302        if ( $title->getNamespace() === NS_MODULE ) {
303            if ( str_ends_with( $title->getText(), '.json' ) ) {
304                $model = CONTENT_MODEL_JSON;
305            } elseif ( !Scribunto::isDocPage( $title ) ) {
306                $model = CONTENT_MODEL_SCRIBUNTO;
307            }
308            return true;
309        }
310        return true;
311    }
312
313    /**
314     * Adds report of number of evaluations by the single wikitext page.
315     *
316     * @param Parser $parser
317     * @param ParserOutput $parserOutput
318     * @return bool
319     */
320    public function onParserLimitReportPrepare( $parser, $parserOutput ) {
321        if ( Scribunto::isParserEnginePresent( $parser ) ) {
322            $engine = Scribunto::getParserEngine( $parser );
323            $engine->reportLimitData( $parserOutput );
324        }
325        return true;
326    }
327
328    /**
329     * Formats the limit report data
330     *
331     * @param string $key
332     * @param mixed &$value
333     * @param string &$report
334     * @param bool $isHTML
335     * @param bool $localize
336     * @return bool
337     */
338    public function onParserLimitReportFormat( $key, &$value, &$report, $isHTML, $localize ) {
339        $engine = Scribunto::newDefaultEngine();
340        return $engine->formatLimitData( $key, $value, $report, $isHTML, $localize );
341    }
342
343    /**
344     * EditPage::showStandardInputs:options hook
345     *
346     * @param EditPage $editor
347     * @param OutputPage $output
348     * @param int &$tab Current tabindex
349     * @return bool
350     */
351    public function onEditPage__showStandardInputs_options( $editor, $output, &$tab ) {
352        if ( $editor->getTitle()->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) {
353            $output->addModules( 'ext.scribunto.edit' );
354            $editor->editFormTextAfterTools .= '<div id="mw-scribunto-console"></div>';
355        }
356        return true;
357    }
358
359    /**
360     * EditPage::showReadOnlyForm:initial hook
361     *
362     * @param EditPage $editor
363     * @param OutputPage $output
364     * @return bool
365     */
366    public function onEditPage__showReadOnlyForm_initial( $editor, $output ) {
367        if ( $editor->getTitle()->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) {
368            $output->addModules( 'ext.scribunto.edit' );
369            $editor->editFormTextAfterContent .= '<div id="mw-scribunto-console"></div>';
370        }
371        return true;
372    }
373
374    /**
375     * EditPageBeforeEditButtons hook
376     *
377     * @param EditPage $editor
378     * @param array &$buttons Button array
379     * @param int &$tabindex Current tabindex
380     * @return bool
381     */
382    public function onEditPageBeforeEditButtons( $editor, &$buttons, &$tabindex ) {
383        if ( $editor->getTitle()->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) {
384            unset( $buttons['preview'] );
385        }
386        return true;
387    }
388
389    /**
390     * @param IContextSource $context
391     * @param Content $content
392     * @param Status $status
393     * @param string $summary
394     * @param User $user
395     * @param bool $minoredit
396     * @return bool
397     */
398    public function onEditFilterMergedContent( IContextSource $context, Content $content,
399        Status $status, $summary, User $user, $minoredit
400    ) {
401        $title = $context->getTitle();
402
403        if ( !$content instanceof ScribuntoContent ) {
404            return true;
405        }
406        $contentHandlerFactory = MediaWikiServices::getInstance()->getContentHandlerFactory();
407        $contentHandler = $contentHandlerFactory->getContentHandler( $content->getModel() );
408
409        '@phan-var ScribuntoContentHandler $contentHandler';
410        $validateStatus = $contentHandler->validate( $content, $title );
411        if ( $validateStatus->isOK() ) {
412            return true;
413        }
414
415        $status->merge( $validateStatus );
416
417        if ( isset( $validateStatus->value->params['module'] ) ) {
418            $module = $validateStatus->value->params['module'];
419            $line = $validateStatus->value->params['line'];
420            if ( $module === $title->getPrefixedDBkey() && preg_match( '/^\d+$/', $line ) ) {
421                $out = $context->getOutput();
422                $out->addInlineScript( 'window.location.hash = ' . Xml::encodeJsVar( "#mw-ce-l$line" ) );
423            }
424        }
425        if ( !$status->isOK() ) {
426            // @todo Remove this line after this extension do not support mediawiki version 1.36 and before
427            $status->value = EditPage::AS_HOOK_ERROR_EXPECTED;
428            return false;
429        }
430
431        return true;
432    }
433
434    /**
435     * @param Article $article
436     * @param bool|ParserOutput|null &$outputDone
437     * @param bool &$pcache
438     * @return bool
439     */
440    public function onArticleViewHeader( $article, &$outputDone, &$pcache ) {
441        $title = $article->getTitle();
442        if ( Scribunto::isDocPage( $title, $forModule ) ) {
443            $article->getContext()->getOutput()->addHTML(
444                wfMessage( 'scribunto-doc-page-header', $forModule->getPrefixedText() )->parseAsBlock()
445            );
446        }
447        return true;
448    }
449}