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