1<?php
2
3namespace MediaWiki\Extensions\ParserFunctions;
4
5use DateTime;
6use DateTimeZone;
7use Exception;
8use ILanguageConverter;
9use Language;
10use MediaWiki\MediaWikiServices;
11use MWTimestamp;
12use Parser;
13use PPFrame;
14use PPNode;
15use Sanitizer;
16use StringUtils;
17use StubObject;
18use Title;
19
20/**
21 * Parser function handlers
22 *
23 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions
24 */
25class ParserFunctions {
26	private static $mExprParser;
27	private static $mTimeCache = [];
28	private static $mTimeChars = 0;
29
30	/** ~10 seconds */
31	private const MAX_TIME_CHARS = 6000;
32
33	/**
34	 * Register ParserClearState hook.
35	 * We defer this until needed to avoid the loading of the code of this file
36	 * when no parser function is actually called.
37	 */
38	private static function registerClearHook() {
39		static $done = false;
40		if ( !$done ) {
41			global $wgHooks;
42			$wgHooks['ParserClearState'][] = function () {
43				self::$mTimeChars = 0;
44			};
45			$done = true;
46		}
47	}
48
49	/**
50	 * @return ExprParser
51	 */
52	private static function &getExprParser() {
53		if ( !isset( self::$mExprParser ) ) {
54			self::$mExprParser = new ExprParser;
55		}
56		return self::$mExprParser;
57	}
58
59	/**
60	 * {{#expr: expression }}
61	 *
62	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##expr
63	 *
64	 * @param Parser $parser
65	 * @param string $expr
66	 * @return string
67	 */
68	public static function expr( Parser $parser, $expr = '' ) {
69		try {
70			return self::getExprParser()->doExpression( $expr );
71		} catch ( ExprError $e ) {
72			return '<strong class="error">' . htmlspecialchars( $e->getUserFriendlyMessage() ) . '</strong>';
73		}
74	}
75
76	/**
77	 * {{#ifexpr: expression | value if true | value if false }}
78	 *
79	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##ifexpr
80	 *
81	 * @param Parser $parser
82	 * @param PPFrame $frame
83	 * @param array $args
84	 * @return string
85	 */
86	public static function ifexpr( Parser $parser, PPFrame $frame, array $args ) {
87		$expr = isset( $args[0] ) ? trim( $frame->expand( $args[0] ) ) : '';
88		$then = $args[1] ?? '';
89		$else = $args[2] ?? '';
90
91		try {
92			$result = self::getExprParser()->doExpression( $expr );
93			if ( is_numeric( $result ) ) {
94				$result = (float)$result;
95			}
96			$result = $result ? $then : $else;
97		} catch ( ExprError $e ) {
98			return '<strong class="error">' . htmlspecialchars( $e->getUserFriendlyMessage() ) . '</strong>';
99		}
100
101		if ( is_object( $result ) ) {
102			$result = trim( $frame->expand( $result ) );
103		}
104
105		return $result;
106	}
107
108	/**
109	 * {{#if: test string | value if test string is not empty | value if test string is empty }}
110	 *
111	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##if
112	 *
113	 * @param Parser $parser
114	 * @param PPFrame $frame
115	 * @param array $args
116	 * @return string
117	 */
118	public static function if( Parser $parser, PPFrame $frame, array $args ) {
119		$test = isset( $args[0] ) ? trim( $frame->expand( $args[0] ) ) : '';
120		if ( $test !== '' ) {
121			return isset( $args[1] ) ? trim( $frame->expand( $args[1] ) ) : '';
122		} else {
123			return isset( $args[2] ) ? trim( $frame->expand( $args[2] ) ) : '';
124		}
125	}
126
127	/**
128	 * {{#ifeq: string 1 | string 2 | value if identical | value if different }}
129	 *
130	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##ifeq
131	 *
132	 * @param Parser $parser
133	 * @param PPFrame $frame
134	 * @param array $args
135	 * @return string
136	 */
137	public static function ifeq( Parser $parser, PPFrame $frame, array $args ) {
138		$left = isset( $args[0] ) ? self::decodeTrimExpand( $args[0], $frame ) : '';
139		$right = isset( $args[1] ) ? self::decodeTrimExpand( $args[1], $frame ) : '';
140
141		// Strict compare is not possible here. 01 should equal 1 for example.
142		/** @noinspection TypeUnsafeComparisonInspection */
143		if ( $left == $right ) {
144			return isset( $args[2] ) ? trim( $frame->expand( $args[2] ) ) : '';
145		} else {
146			return isset( $args[3] ) ? trim( $frame->expand( $args[3] ) ) : '';
147		}
148	}
149
150	/**
151	 * {{#iferror: test string | value if error | value if no error }}
152	 *
153	 * Error is when the input string contains an HTML object with class="error", as
154	 * generated by other parser functions such as #expr, #time and #rel2abs.
155	 *
156	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##iferror
157	 *
158	 * @param Parser $parser
159	 * @param PPFrame $frame
160	 * @param array $args
161	 * @return string
162	 */
163	public static function iferror( Parser $parser, PPFrame $frame, array $args ) {
164		$test = isset( $args[0] ) ? trim( $frame->expand( $args[0] ) ) : '';
165		$then = $args[1] ?? false;
166		$else = $args[2] ?? false;
167
168		if ( preg_match(
169			'/<(?:strong|span|p|div)\s(?:[^\s>]*\s+)*?class="(?:[^"\s>]*\s+)*?error(?:\s[^">]*)?"/',
170			$test )
171		) {
172			$result = $then;
173		} elseif ( $else === false ) {
174			$result = $test;
175		} else {
176			$result = $else;
177		}
178		if ( $result === false ) {
179			return '';
180		}
181
182		return trim( $frame->expand( $result ) );
183	}
184
185	/**
186	 * {{#switch: comparison string
187	 * | case = result
188	 * | case = result
189	 * | ...
190	 * | default result
191	 * }}
192	 *
193	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##switch
194	 *
195	 * @param Parser $parser
196	 * @param PPFrame $frame
197	 * @param array $args
198	 * @return string
199	 */
200	public static function switch( Parser $parser, PPFrame $frame, array $args ) {
201		if ( count( $args ) === 0 ) {
202			return '';
203		}
204		$primary = self::decodeTrimExpand( array_shift( $args ), $frame );
205		$found = $defaultFound = false;
206		$default = null;
207		$lastItemHadNoEquals = false;
208		$lastItem = '';
209		$mwDefault = $parser->getMagicWordFactory()->get( 'default' );
210		foreach ( $args as $arg ) {
211			$bits = $arg->splitArg();
212			$nameNode = $bits['name'];
213			$index = $bits['index'];
214			$valueNode = $bits['value'];
215
216			if ( $index === '' ) {
217				# Found "="
218				$lastItemHadNoEquals = false;
219				if ( $found ) {
220					# Multiple input match
221					return trim( $frame->expand( $valueNode ) );
222				} else {
223					$test = self::decodeTrimExpand( $nameNode, $frame );
224					/** @noinspection TypeUnsafeComparisonInspection */
225					if ( $test == $primary ) {
226						# Found a match, return now
227						return trim( $frame->expand( $valueNode ) );
228					} elseif ( $defaultFound || $mwDefault->matchStartToEnd( $test ) ) {
229						$default = $valueNode;
230						$defaultFound = false;
231					} # else wrong case, continue
232				}
233			} else {
234				# Multiple input, single output
235				# If the value matches, set a flag and continue
236				$lastItemHadNoEquals = true;
237				// $lastItem is an "out" variable
238				$decodedTest = self::decodeTrimExpand( $valueNode, $frame, $lastItem );
239				/** @noinspection TypeUnsafeComparisonInspection */
240				if ( $decodedTest == $primary ) {
241					$found = true;
242				} elseif ( $mwDefault->matchStartToEnd( $decodedTest ) ) {
243					$defaultFound = true;
244				}
245			}
246		}
247		# Default case
248		# Check if the last item had no = sign, thus specifying the default case
249		if ( $lastItemHadNoEquals ) {
250			return $lastItem;
251		} elseif ( $default !== null ) {
252			return trim( $frame->expand( $default ) );
253		} else {
254			return '';
255		}
256	}
257
258	/**
259	 * {{#rel2abs: path }} or {{#rel2abs: path | base path }}
260	 *
261	 * Returns the absolute path to a subpage, relative to the current article
262	 * title. Treats titles as slash-separated paths.
263	 *
264	 * Following subpage link syntax instead of standard path syntax, an
265	 * initial slash is treated as a relative path, and vice versa.
266	 *
267	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##rel2abs
268	 *
269	 * @param Parser $parser
270	 * @param string $to
271	 * @param string $from
272	 *
273	 * @return string
274	 */
275	public static function rel2abs( Parser $parser, $to = '', $from = '' ) {
276		$from = trim( $from );
277		if ( $from === '' ) {
278			$from = $parser->getTitle()->getPrefixedText();
279		}
280
281		$to = rtrim( $to, ' /' );
282
283		// if we have an empty path, or just one containing a dot
284		if ( $to === '' || $to === '.' ) {
285			return $from;
286		}
287
288		// if the path isn't relative
289		if ( substr( $to, 0, 1 ) !== '/' &&
290			substr( $to, 0, 2 ) !== './' &&
291			substr( $to, 0, 3 ) !== '../' &&
292			$to !== '..'
293		) {
294			$from = '';
295		}
296		// Make a long path, containing both, enclose it in /.../
297		$fullPath = '/' . $from . '/' . $to . '/';
298
299		// remove redundant current path dots
300		$fullPath = preg_replace( '!/(\./)+!', '/', $fullPath );
301
302		// remove double slashes
303		$fullPath = preg_replace( '!/{2,}!', '/', $fullPath );
304
305		// remove the enclosing slashes now
306		$fullPath = trim( $fullPath, '/' );
307		$exploded = explode( '/', $fullPath );
308		$newExploded = [];
309
310		foreach ( $exploded as $current ) {
311			if ( $current === '..' ) { // removing one level
312				if ( !count( $newExploded ) ) {
313					// attempted to access a node above root node
314					$msg = wfMessage( 'pfunc_rel2abs_invalid_depth', $fullPath )
315						->inContentLanguage()->escaped();
316					return '<strong class="error">' . $msg . '</strong>';
317				}
318				// remove last level from the stack
319				array_pop( $newExploded );
320			} else {
321				// add the current level to the stack
322				$newExploded[] = $current;
323			}
324		}
325
326		// we can now join it again
327		return implode( '/', $newExploded );
328	}
329
330	/**
331	 * @param Parser $parser
332	 * @param PPFrame $frame
333	 * @param string $titletext
334	 * @param string $then
335	 * @param string $else
336	 *
337	 * @return string
338	 */
339	private static function ifexistInternal(
340		Parser $parser, PPFrame $frame, $titletext = '', $then = '', $else = ''
341	) {
342		$title = Title::newFromText( $titletext );
343		self::getLanguageConverter( $parser->getContentLanguage() )
344			->findVariantLink( $titletext, $title, true );
345		if ( $title ) {
346			if ( $title->getNamespace() === NS_MEDIA ) {
347				/* If namespace is specified as NS_MEDIA, then we want to
348				 * check the physical file, not the "description" page.
349				 */
350				if ( !$parser->incrementExpensiveFunctionCount() ) {
351					return $else;
352				}
353				$file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title );
354				if ( !$file ) {
355					return $else;
356				}
357				$parser->getOutput()->addImage(
358					$file->getName(), $file->getTimestamp(), $file->getSha1() );
359				return $file->exists() ? $then : $else;
360			} elseif ( $title->isSpecialPage() ) {
361				/* Don't bother with the count for special pages,
362				 * since their existence can be checked without
363				 * accessing the database.
364				 */
365				return MediaWikiServices::getInstance()->getSpecialPageFactory()
366					->exists( $title->getDBkey() ) ? $then : $else;
367			} elseif ( $title->isExternal() ) {
368				/* Can't check the existence of pages on other sites,
369				 * so just return $else.  Makes a sort of sense, since
370				 * they don't exist _locally_.
371				 */
372				return $else;
373			} else {
374				$pdbk = $title->getPrefixedDBkey();
375				$lc = MediaWikiServices::getInstance()->getLinkCache();
376				$id = $lc->getGoodLinkID( $pdbk );
377				if ( $id !== 0 ) {
378					$parser->getOutput()->addLink( $title, $id );
379					return $then;
380				} elseif ( $lc->isBadLink( $pdbk ) ) {
381					$parser->getOutput()->addLink( $title, 0 );
382					return $else;
383				}
384				if ( !$parser->incrementExpensiveFunctionCount() ) {
385					return $else;
386				}
387				$id = $title->getArticleID();
388				$parser->getOutput()->addLink( $title, $id );
389
390				// bug 70495: don't just check whether the ID != 0
391				if ( $title->exists() ) {
392					return $then;
393				}
394			}
395		}
396		return $else;
397	}
398
399	/**
400	 * {{#ifexist: page title | value if exists | value if doesn't exist }}
401	 *
402	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##ifexist
403	 *
404	 * @param Parser $parser
405	 * @param PPFrame $frame
406	 * @param array $args
407	 * @return string
408	 */
409	public static function ifexist( Parser $parser, PPFrame $frame, array $args ) {
410		$title = isset( $args[0] ) ? trim( $frame->expand( $args[0] ) ) : '';
411		$then = $args[1] ?? null;
412		$else = $args[2] ?? null;
413
414		$result = self::ifexistInternal( $parser, $frame, $title, $then, $else );
415		if ( $result === null ) {
416			return '';
417		} else {
418			return trim( $frame->expand( $result ) );
419		}
420	}
421
422	/**
423	 * Used by time() and localTime()
424	 *
425	 * @param Parser $parser
426	 * @param PPFrame|null $frame
427	 * @param string $format
428	 * @param string $date
429	 * @param string $language
430	 * @param string|bool $local
431	 * @return string
432	 */
433	private static function timeCommon(
434		Parser $parser, PPFrame $frame = null, $format = '', $date = '', $language = '', $local = false
435	) {
436		global $wgLocaltimezone;
437		self::registerClearHook();
438		if ( $date === '' ) {
439			$cacheKey = $parser->getOptions()->getTimestamp();
440			$timestamp = new MWTimestamp( $cacheKey );
441			$date = $timestamp->getTimestamp( TS_ISO_8601 );
442			$useTTL = true;
443		} else {
444			$cacheKey = $date;
445			$useTTL = false;
446		}
447		if ( isset( self::$mTimeCache[$format][$cacheKey][$language][$local] ) ) {
448			$cachedVal = self::$mTimeCache[$format][$cacheKey][$language][$local];
449			if ( $useTTL && $cachedVal[1] !== null && $frame ) {
450				$frame->setTTL( $cachedVal[1] );
451			}
452			return $cachedVal[0];
453		}
454
455		# compute the timestamp string $ts
456		# PHP >= 5.2 can handle dates before 1970 or after 2038 using the DateTime object
457
458		$invalidTime = false;
459
460		# the DateTime constructor must be used because it throws exceptions
461		# when errors occur, whereas date_create appears to just output a warning
462		# that can't really be detected from within the code
463		try {
464
465			# Default input timezone is UTC.
466			$utc = new DateTimeZone( 'UTC' );
467
468			# Correct for DateTime interpreting 'XXXX' as XX:XX o'clock
469			if ( preg_match( '/^[0-9]{4}$/', $date ) ) {
470				$date = '00:00 ' . $date;
471			}
472
473			# Parse date
474			# UTC is a default input timezone.
475			$dateObject = new DateTime( $date, $utc );
476
477			# Set output timezone.
478			if ( $local ) {
479				if ( isset( $wgLocaltimezone ) ) {
480					$tz = new DateTimeZone( $wgLocaltimezone );
481				} else {
482					$tz = new DateTimeZone( date_default_timezone_get() );
483				}
484			} else {
485				$tz = $utc;
486			}
487			$dateObject->setTimezone( $tz );
488			# Generate timestamp
489			$ts = $dateObject->format( 'YmdHis' );
490
491		} catch ( Exception $ex ) {
492			$invalidTime = true;
493		}
494
495		$ttl = null;
496		# format the timestamp and return the result
497		if ( $invalidTime ) {
498			$result = '<strong class="error">' .
499				wfMessage( 'pfunc_time_error' )->inContentLanguage()->escaped() .
500				'</strong>';
501		} else {
502			self::$mTimeChars += strlen( $format );
503			if ( self::$mTimeChars > self::MAX_TIME_CHARS ) {
504				return '<strong class="error">' .
505					wfMessage( 'pfunc_time_too_long' )->inContentLanguage()->escaped() .
506					'</strong>';
507			} else {
508				if ( $ts < 0 ) { // Language can't deal with BC years
509					return '<strong class="error">' .
510						wfMessage( 'pfunc_time_too_small' )->inContentLanguage()->escaped() .
511						'</strong>';
512				} elseif ( $ts < 100000000000000 ) { // Language can't deal with years after 9999
513					if ( $language !== '' && Language::isValidBuiltInCode( $language ) ) {
514						// use whatever language is passed as a parameter
515						$langObject = Language::factory( $language );
516					} else {
517						// use wiki's content language
518						$langObject = $parser->getFunctionLang();
519						// $ttl is passed by reference, which doesn't work right on stub objects
520						StubObject::unstub( $langObject );
521					}
522					$result = $langObject->sprintfDate( $format, $ts, $tz, $ttl );
523				} else {
524					return '<strong class="error">' .
525						wfMessage( 'pfunc_time_too_big' )->inContentLanguage()->escaped() .
526						'</strong>';
527				}
528			}
529		}
530		self::$mTimeCache[$format][$cacheKey][$language][$local] = [ $result, $ttl ];
531		if ( $useTTL && $ttl !== null && $frame ) {
532			$frame->setTTL( $ttl );
533		}
534		return $result;
535	}
536
537	/**
538	 * {{#time: format string }}
539	 * {{#time: format string | date/time object }}
540	 * {{#time: format string | date/time object | language code }}
541	 *
542	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##time
543	 *
544	 * @param Parser $parser
545	 * @param PPFrame $frame
546	 * @param array $args
547	 * @return string
548	 */
549	public static function time( Parser $parser, PPFrame $frame, array $args ) {
550		$format = isset( $args[0] ) ? trim( $frame->expand( $args[0] ) ) : '';
551		$date = isset( $args[1] ) ? trim( $frame->expand( $args[1] ) ) : '';
552		$language = isset( $args[2] ) ? trim( $frame->expand( $args[2] ) ) : '';
553		$local = isset( $args[3] ) && trim( $frame->expand( $args[3] ) );
554		return self::timeCommon( $parser, $frame, $format, $date, $language, $local );
555	}
556
557	/**
558	 * {{#timel: ... }}
559	 *
560	 * Identical to {{#time: ... }}, except that it uses the local time of the wiki
561	 * (as set in $wgLocaltimezone) when no date is given.
562	 *
563	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##timel
564	 *
565	 * @param Parser $parser
566	 * @param PPFrame $frame
567	 * @param array $args
568	 * @return string
569	 */
570	public static function localTime( Parser $parser, PPFrame $frame, array $args ) {
571		$format = isset( $args[0] ) ? trim( $frame->expand( $args[0] ) ) : '';
572		$date = isset( $args[1] ) ? trim( $frame->expand( $args[1] ) ) : '';
573		$language = isset( $args[2] ) ? trim( $frame->expand( $args[2] ) ) : '';
574		return self::timeCommon( $parser, $frame, $format, $date, $language, true );
575	}
576
577	/**
578	 * Obtain a specified number of slash-separated parts of a title,
579	 * e.g. {{#titleparts:Hello/World|1}} => "Hello"
580	 *
581	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##titleparts
582	 *
583	 * @param Parser $parser Parent parser
584	 * @param string $title Title to split
585	 * @param int $parts Number of parts to keep
586	 * @param int $offset Offset starting at 1
587	 * @return string
588	 */
589	public static function titleparts( Parser $parser, $title = '', $parts = 0, $offset = 0 ) {
590		$parts = (int)$parts;
591		$offset = (int)$offset;
592		$ntitle = Title::newFromText( $title );
593		if ( $ntitle instanceof Title ) {
594			$bits = explode( '/', $ntitle->getPrefixedText(), 25 );
595			if ( count( $bits ) <= 0 ) {
596				return $ntitle->getPrefixedText();
597			} else {
598				if ( $offset > 0 ) {
599					--$offset;
600				}
601				if ( $parts === 0 ) {
602					return implode( '/', array_slice( $bits, $offset ) );
603				} else {
604					return implode( '/', array_slice( $bits, $offset, $parts ) );
605				}
606			}
607		} else {
608			return $title;
609		}
610	}
611
612	/**
613	 * Verifies parameter is less than max string length.
614	 *
615	 * @param string $text
616	 * @return bool
617	 */
618	private static function checkLength( $text ) {
619		global $wgPFStringLengthLimit;
620		return ( mb_strlen( $text ) < $wgPFStringLengthLimit );
621	}
622
623	/**
624	 * Generates error message. Called when string is too long.
625	 * @return string
626	 */
627	private static function tooLongError() {
628		global $wgPFStringLengthLimit;
629		$msg = wfMessage( 'pfunc_string_too_long' )->numParams( $wgPFStringLengthLimit );
630		return '<strong class="error">' . $msg->inContentLanguage()->escaped() . '</strong>';
631	}
632
633	/**
634	 * {{#len:string}}
635	 *
636	 * Reports number of characters in string.
637	 *
638	 * @param Parser $parser
639	 * @param string $inStr
640	 * @return int
641	 */
642	public static function runLen( Parser $parser, $inStr = '' ) {
643		$inStr = $parser->killMarkers( (string)$inStr );
644		return mb_strlen( $inStr );
645	}
646
647	/**
648	 * {{#pos: string | needle | offset}}
649	 *
650	 * Finds first occurrence of "needle" in "string" starting at "offset".
651	 *
652	 * Note: If the needle is an empty string, single space is used instead.
653	 * Note: If the needle is not found, empty string is returned.
654	 * @param Parser $parser
655	 * @param string $inStr
656	 * @param int|string $inNeedle
657	 * @param int $inOffset
658	 * @return int|string
659	 */
660	public static function runPos( Parser $parser, $inStr = '', $inNeedle = '', $inOffset = 0 ) {
661		$inStr = $parser->killMarkers( (string)$inStr );
662		$inNeedle = $parser->killMarkers( (string)$inNeedle );
663
664		if ( !self::checkLength( $inStr ) ||
665			!self::checkLength( $inNeedle ) ) {
666			return self::tooLongError();
667		}
668
669		if ( $inNeedle === '' ) {
670			$inNeedle = ' ';
671		}
672
673		$pos = mb_strpos( $inStr, $inNeedle, min( (int)$inOffset, mb_strlen( $inStr ) ) );
674		if ( $pos === false ) {
675			$pos = '';
676		}
677
678		return $pos;
679	}
680
681	/**
682	 * {{#rpos: string | needle}}
683	 *
684	 * Finds last occurrence of "needle" in "string".
685	 *
686	 * Note: If the needle is an empty string, single space is used instead.
687	 * Note: If the needle is not found, -1 is returned.
688	 * @param Parser $parser
689	 * @param string $inStr
690	 * @param int|string $inNeedle
691	 * @return int|string
692	 */
693	public static function runRPos( Parser $parser, $inStr = '', $inNeedle = '' ) {
694		$inStr = $parser->killMarkers( (string)$inStr );
695		$inNeedle = $parser->killMarkers( (string)$inNeedle );
696
697		if ( !self::checkLength( $inStr ) ||
698			!self::checkLength( $inNeedle ) ) {
699			return self::tooLongError();
700		}
701
702		if ( $inNeedle === '' ) {
703			$inNeedle = ' ';
704		}
705
706		$pos = mb_strrpos( $inStr, $inNeedle );
707		if ( $pos === false ) {
708			$pos = -1;
709		}
710
711		return $pos;
712	}
713
714	/**
715	 * {{#sub: string | start | length }}
716	 *
717	 * Returns substring of "string" starting at "start" and having
718	 * "length" characters.
719	 *
720	 * Note: If length is zero, the rest of the input is returned.
721	 * Note: A negative value for "start" operates from the end of the
722	 *   "string".
723	 * Note: A negative value for "length" returns a string reduced in
724	 *   length by that amount.
725	 *
726	 * @param Parser $parser
727	 * @param string $inStr
728	 * @param int $inStart
729	 * @param int $inLength
730	 * @return string
731	 */
732	public static function runSub( Parser $parser, $inStr = '', $inStart = 0, $inLength = 0 ) {
733		$inStr = $parser->killMarkers( (string)$inStr );
734
735		if ( !self::checkLength( $inStr ) ) {
736			return self::tooLongError();
737		}
738
739		if ( (int)$inLength === 0 ) {
740			$result = mb_substr( $inStr, (int)$inStart );
741		} else {
742			$result = mb_substr( $inStr, (int)$inStart, (int)$inLength );
743		}
744
745		return $result;
746	}
747
748	/**
749	 * {{#count: string | substr }}
750	 *
751	 * Returns number of occurrences of "substr" in "string".
752	 *
753	 * Note: If "substr" is empty, a single space is used.
754	 *
755	 * @param Parser $parser
756	 * @param string $inStr
757	 * @param string $inSubStr
758	 * @return int|string
759	 */
760	public static function runCount( Parser $parser, $inStr = '', $inSubStr = '' ) {
761		$inStr = $parser->killMarkers( (string)$inStr );
762		$inSubStr = $parser->killMarkers( (string)$inSubStr );
763
764		if ( !self::checkLength( $inStr ) ||
765			!self::checkLength( $inSubStr ) ) {
766			return self::tooLongError();
767		}
768
769		if ( $inSubStr === '' ) {
770			$inSubStr = ' ';
771		}
772
773		$result = mb_substr_count( $inStr, $inSubStr );
774
775		return $result;
776	}
777
778	/**
779	 * {{#replace:string | from | to | limit }}
780	 *
781	 * Replaces each occurrence of "from" in "string" with "to".
782	 * At most "limit" replacements are performed.
783	 *
784	 * Note: Armored against replacements that would generate huge strings.
785	 * Note: If "from" is an empty string, single space is used instead.
786	 *
787	 * @param Parser $parser
788	 * @param string $inStr
789	 * @param string $inReplaceFrom
790	 * @param string $inReplaceTo
791	 * @param int $inLimit
792	 * @return mixed|string
793	 */
794	public static function runReplace( Parser $parser, $inStr = '',
795			$inReplaceFrom = '', $inReplaceTo = '', $inLimit = -1 ) {
796		global $wgPFStringLengthLimit;
797
798		$inStr = $parser->killMarkers( (string)$inStr );
799		$inReplaceFrom = $parser->killMarkers( (string)$inReplaceFrom );
800		$inReplaceTo = $parser->killMarkers( (string)$inReplaceTo );
801
802		if ( !self::checkLength( $inStr ) ||
803			!self::checkLength( $inReplaceFrom ) ||
804			!self::checkLength( $inReplaceTo ) ) {
805			return self::tooLongError();
806		}
807
808		if ( $inReplaceFrom === '' ) {
809			$inReplaceFrom = ' ';
810		}
811
812		// Precompute limit to avoid generating enormous string:
813		$diff = mb_strlen( $inReplaceTo ) - mb_strlen( $inReplaceFrom );
814		if ( $diff > 0 ) {
815			$limit = ( ( $wgPFStringLengthLimit - mb_strlen( $inStr ) ) / $diff ) + 1;
816		} else {
817			$limit = -1;
818		}
819
820		$inLimit = (int)$inLimit;
821		if ( $inLimit >= 0 ) {
822			if ( $limit > $inLimit || $limit == -1 ) {
823				$limit = $inLimit;
824			}
825		}
826
827		// Use regex to allow limit and handle UTF-8 correctly.
828		$inReplaceFrom = preg_quote( $inReplaceFrom, '/' );
829		$inReplaceTo = StringUtils::escapeRegexReplacement( $inReplaceTo );
830
831		$result = preg_replace( '/' . $inReplaceFrom . '/u',
832						$inReplaceTo, $inStr, $limit );
833
834		if ( !self::checkLength( $result ) ) {
835			return self::tooLongError();
836		}
837
838		return $result;
839	}
840
841	/**
842	 * {{#explode:string | delimiter | position | limit}}
843	 *
844	 * Breaks "string" into chunks separated by "delimiter" and returns the
845	 * chunk identified by "position".
846	 *
847	 * Note: Negative position can be used to specify tokens from the end.
848	 * Note: If the divider is an empty string, single space is used instead.
849	 * Note: Empty string is returned if there are not enough exploded chunks.
850	 *
851	 * @param Parser $parser
852	 * @param string $inStr
853	 * @param string $inDiv
854	 * @param int $inPos
855	 * @param int|null $inLim
856	 * @return string
857	 */
858	public static function runExplode(
859		Parser $parser, $inStr = '', $inDiv = '', $inPos = 0, $inLim = null
860	) {
861		$inStr = $parser->killMarkers( (string)$inStr );
862		$inDiv = $parser->killMarkers( (string)$inDiv );
863
864		if ( $inDiv === '' ) {
865			$inDiv = ' ';
866		}
867
868		if ( !self::checkLength( $inStr ) ||
869			!self::checkLength( $inDiv ) ) {
870			return self::tooLongError();
871		}
872
873		$inDiv = preg_quote( $inDiv, '/' );
874
875		$matches = preg_split( '/' . $inDiv . '/u', $inStr, $inLim );
876
877		if ( $inPos >= 0 && isset( $matches[$inPos] ) ) {
878			$result = $matches[$inPos];
879		} elseif ( $inPos < 0 && isset( $matches[count( $matches ) + $inPos] ) ) {
880			$result = $matches[count( $matches ) + $inPos];
881		} else {
882			$result = '';
883		}
884
885		return $result;
886	}
887
888	/**
889	 * {{#urldecode:string}}
890	 *
891	 * Decodes URL-encoded (like%20that) strings.
892	 *
893	 * @param Parser $parser
894	 * @param string $inStr
895	 * @return string
896	 */
897	public static function runUrlDecode( Parser $parser, $inStr = '' ) {
898		$inStr = $parser->killMarkers( (string)$inStr );
899		if ( !self::checkLength( $inStr ) ) {
900			return self::tooLongError();
901		}
902
903		return urldecode( $inStr );
904	}
905
906	/**
907	 * Take a PPNode (-ish thing), expand it, remove entities, and trim.
908	 *
909	 * For use when doing string comparisions, where user expects entities
910	 * to be equal for what they stand for (e.g. comparisions with {{PAGENAME}})
911	 *
912	 * @param PPNode|string $obj Thing to expand
913	 * @param PPFrame $frame
914	 * @param string|null &$trimExpanded Expanded and trimmed version of PPNode,
915	 *   but with char refs intact
916	 * @return string The trimmed, expanded and entity reference decoded version of the PPNode
917	 */
918	private static function decodeTrimExpand( $obj, PPFrame $frame, &$trimExpanded = null ) {
919		$expanded = $frame->expand( $obj );
920		$trimExpanded = trim( $expanded );
921		return trim( Sanitizer::decodeCharReferences( $expanded ) );
922	}
923
924	/**
925	 * @since 1.35
926	 * @param Language $language
927	 * @return ILanguageConverter
928	 */
929	private static function getLanguageConverter( Language $language ) : ILanguageConverter {
930		return MediaWikiServices::getInstance()
931			->getLanguageConverterFactory()
932			->getLanguageConverter( $language );
933	}
934}
935