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'][] = static 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					$parser->getOutput()->addImage(
356						$title->getDBKey(), false, false );
357					return $else;
358				}
359				$parser->getOutput()->addImage(
360					$file->getName(), $file->getTimestamp(), $file->getSha1() );
361				return $file->exists() ? $then : $else;
362			} elseif ( $title->isSpecialPage() ) {
363				/* Don't bother with the count for special pages,
364				 * since their existence can be checked without
365				 * accessing the database.
366				 */
367				return MediaWikiServices::getInstance()->getSpecialPageFactory()
368					->exists( $title->getDBkey() ) ? $then : $else;
369			} elseif ( $title->isExternal() ) {
370				/* Can't check the existence of pages on other sites,
371				 * so just return $else.  Makes a sort of sense, since
372				 * they don't exist _locally_.
373				 */
374				return $else;
375			} else {
376				$pdbk = $title->getPrefixedDBkey();
377				$lc = MediaWikiServices::getInstance()->getLinkCache();
378				$id = $lc->getGoodLinkID( $pdbk );
379				if ( $id !== 0 ) {
380					$parser->getOutput()->addLink( $title, $id );
381					return $then;
382				} elseif ( $lc->isBadLink( $pdbk ) ) {
383					$parser->getOutput()->addLink( $title, 0 );
384					return $else;
385				}
386				if ( !$parser->incrementExpensiveFunctionCount() ) {
387					return $else;
388				}
389				$id = $title->getArticleID();
390				$parser->getOutput()->addLink( $title, $id );
391
392				// bug 70495: don't just check whether the ID != 0
393				if ( $title->exists() ) {
394					return $then;
395				}
396			}
397		}
398		return $else;
399	}
400
401	/**
402	 * {{#ifexist: page title | value if exists | value if doesn't exist }}
403	 *
404	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##ifexist
405	 *
406	 * @param Parser $parser
407	 * @param PPFrame $frame
408	 * @param array $args
409	 * @return string
410	 */
411	public static function ifexist( Parser $parser, PPFrame $frame, array $args ) {
412		$title = isset( $args[0] ) ? trim( $frame->expand( $args[0] ) ) : '';
413		$then = $args[1] ?? null;
414		$else = $args[2] ?? null;
415
416		$result = self::ifexistInternal( $parser, $frame, $title, $then, $else );
417		if ( $result === null ) {
418			return '';
419		} else {
420			return trim( $frame->expand( $result ) );
421		}
422	}
423
424	/**
425	 * Used by time() and localTime()
426	 *
427	 * @param Parser $parser
428	 * @param PPFrame|null $frame
429	 * @param string $format
430	 * @param string $date
431	 * @param string $language
432	 * @param string|bool $local
433	 * @return string
434	 */
435	private static function timeCommon(
436		Parser $parser, PPFrame $frame = null, $format = '', $date = '', $language = '', $local = false
437	) {
438		global $wgLocaltimezone;
439		self::registerClearHook();
440		if ( $date === '' ) {
441			$cacheKey = $parser->getOptions()->getTimestamp();
442			$timestamp = new MWTimestamp( $cacheKey );
443			$date = $timestamp->getTimestamp( TS_ISO_8601 );
444			$useTTL = true;
445		} else {
446			$cacheKey = $date;
447			$useTTL = false;
448		}
449		if ( isset( self::$mTimeCache[$format][$cacheKey][$language][$local] ) ) {
450			$cachedVal = self::$mTimeCache[$format][$cacheKey][$language][$local];
451			if ( $useTTL && $cachedVal[1] !== null && $frame ) {
452				$frame->setTTL( $cachedVal[1] );
453			}
454			return $cachedVal[0];
455		}
456
457		# compute the timestamp string $ts
458		# PHP >= 5.2 can handle dates before 1970 or after 2038 using the DateTime object
459
460		$invalidTime = false;
461
462		# the DateTime constructor must be used because it throws exceptions
463		# when errors occur, whereas date_create appears to just output a warning
464		# that can't really be detected from within the code
465		try {
466
467			# Default input timezone is UTC.
468			$utc = new DateTimeZone( 'UTC' );
469
470			# Correct for DateTime interpreting 'XXXX' as XX:XX o'clock
471			if ( preg_match( '/^[0-9]{4}$/', $date ) ) {
472				$date = '00:00 ' . $date;
473			}
474
475			# Parse date
476			# UTC is a default input timezone.
477			$dateObject = new DateTime( $date, $utc );
478
479			# Set output timezone.
480			if ( $local ) {
481				if ( isset( $wgLocaltimezone ) ) {
482					$tz = new DateTimeZone( $wgLocaltimezone );
483				} else {
484					$tz = new DateTimeZone( date_default_timezone_get() );
485				}
486			} else {
487				$tz = $utc;
488			}
489			$dateObject->setTimezone( $tz );
490			# Generate timestamp
491			$ts = $dateObject->format( 'YmdHis' );
492
493		} catch ( Exception $ex ) {
494			$invalidTime = true;
495		}
496
497		$ttl = null;
498		# format the timestamp and return the result
499		if ( $invalidTime ) {
500			$result = '<strong class="error">' .
501				wfMessage( 'pfunc_time_error' )->inContentLanguage()->escaped() .
502				'</strong>';
503		} else {
504			self::$mTimeChars += strlen( $format );
505			if ( self::$mTimeChars > self::MAX_TIME_CHARS ) {
506				return '<strong class="error">' .
507					wfMessage( 'pfunc_time_too_long' )->inContentLanguage()->escaped() .
508					'</strong>';
509			} else {
510				if ( $ts < 0 ) { // Language can't deal with BC years
511					return '<strong class="error">' .
512						wfMessage( 'pfunc_time_too_small' )->inContentLanguage()->escaped() .
513						'</strong>';
514				} elseif ( $ts < 100000000000000 ) { // Language can't deal with years after 9999
515					if ( $language !== '' && Language::isValidBuiltInCode( $language ) ) {
516						// use whatever language is passed as a parameter
517						$langObject = Language::factory( $language );
518					} else {
519						// use wiki's content language
520						$langObject = $parser->getFunctionLang();
521						// $ttl is passed by reference, which doesn't work right on stub objects
522						StubObject::unstub( $langObject );
523					}
524					$result = $langObject->sprintfDate( $format, $ts, $tz, $ttl );
525				} else {
526					return '<strong class="error">' .
527						wfMessage( 'pfunc_time_too_big' )->inContentLanguage()->escaped() .
528						'</strong>';
529				}
530			}
531		}
532		self::$mTimeCache[$format][$cacheKey][$language][$local] = [ $result, $ttl ];
533		if ( $useTTL && $ttl !== null && $frame ) {
534			$frame->setTTL( $ttl );
535		}
536		return $result;
537	}
538
539	/**
540	 * {{#time: format string }}
541	 * {{#time: format string | date/time object }}
542	 * {{#time: format string | date/time object | language code }}
543	 *
544	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##time
545	 *
546	 * @param Parser $parser
547	 * @param PPFrame $frame
548	 * @param array $args
549	 * @return string
550	 */
551	public static function time( Parser $parser, PPFrame $frame, array $args ) {
552		$format = isset( $args[0] ) ? trim( $frame->expand( $args[0] ) ) : '';
553		$date = isset( $args[1] ) ? trim( $frame->expand( $args[1] ) ) : '';
554		$language = isset( $args[2] ) ? trim( $frame->expand( $args[2] ) ) : '';
555		$local = isset( $args[3] ) && trim( $frame->expand( $args[3] ) );
556		return self::timeCommon( $parser, $frame, $format, $date, $language, $local );
557	}
558
559	/**
560	 * {{#timel: ... }}
561	 *
562	 * Identical to {{#time: ... }}, except that it uses the local time of the wiki
563	 * (as set in $wgLocaltimezone) when no date is given.
564	 *
565	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##timel
566	 *
567	 * @param Parser $parser
568	 * @param PPFrame $frame
569	 * @param array $args
570	 * @return string
571	 */
572	public static function localTime( Parser $parser, PPFrame $frame, array $args ) {
573		$format = isset( $args[0] ) ? trim( $frame->expand( $args[0] ) ) : '';
574		$date = isset( $args[1] ) ? trim( $frame->expand( $args[1] ) ) : '';
575		$language = isset( $args[2] ) ? trim( $frame->expand( $args[2] ) ) : '';
576		return self::timeCommon( $parser, $frame, $format, $date, $language, true );
577	}
578
579	/**
580	 * Obtain a specified number of slash-separated parts of a title,
581	 * e.g. {{#titleparts:Hello/World|1}} => "Hello"
582	 *
583	 * @link https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##titleparts
584	 *
585	 * @param Parser $parser Parent parser
586	 * @param string $title Title to split
587	 * @param int $parts Number of parts to keep
588	 * @param int $offset Offset starting at 1
589	 * @return string
590	 */
591	public static function titleparts( Parser $parser, $title = '', $parts = 0, $offset = 0 ) {
592		$parts = (int)$parts;
593		$offset = (int)$offset;
594		$ntitle = Title::newFromText( $title );
595		if ( $ntitle instanceof Title ) {
596			$bits = explode( '/', $ntitle->getPrefixedText(), 25 );
597			if ( count( $bits ) <= 0 ) {
598				return $ntitle->getPrefixedText();
599			} else {
600				if ( $offset > 0 ) {
601					--$offset;
602				}
603				if ( $parts === 0 ) {
604					return implode( '/', array_slice( $bits, $offset ) );
605				} else {
606					return implode( '/', array_slice( $bits, $offset, $parts ) );
607				}
608			}
609		} else {
610			return $title;
611		}
612	}
613
614	/**
615	 * Verifies parameter is less than max string length.
616	 *
617	 * @param string $text
618	 * @return bool
619	 */
620	private static function checkLength( $text ) {
621		global $wgPFStringLengthLimit;
622		return ( mb_strlen( $text ) < $wgPFStringLengthLimit );
623	}
624
625	/**
626	 * Generates error message. Called when string is too long.
627	 * @return string
628	 */
629	private static function tooLongError() {
630		global $wgPFStringLengthLimit;
631		$msg = wfMessage( 'pfunc_string_too_long' )->numParams( $wgPFStringLengthLimit );
632		return '<strong class="error">' . $msg->inContentLanguage()->escaped() . '</strong>';
633	}
634
635	/**
636	 * {{#len:string}}
637	 *
638	 * Reports number of characters in string.
639	 *
640	 * @param Parser $parser
641	 * @param string $inStr
642	 * @return int
643	 */
644	public static function runLen( Parser $parser, $inStr = '' ) {
645		$inStr = $parser->killMarkers( (string)$inStr );
646		return mb_strlen( $inStr );
647	}
648
649	/**
650	 * {{#pos: string | needle | offset}}
651	 *
652	 * Finds first occurrence of "needle" in "string" starting at "offset".
653	 *
654	 * Note: If the needle is an empty string, single space is used instead.
655	 * Note: If the needle is not found, empty string is returned.
656	 * @param Parser $parser
657	 * @param string $inStr
658	 * @param int|string $inNeedle
659	 * @param int $inOffset
660	 * @return int|string
661	 */
662	public static function runPos( Parser $parser, $inStr = '', $inNeedle = '', $inOffset = 0 ) {
663		$inStr = $parser->killMarkers( (string)$inStr );
664		$inNeedle = $parser->killMarkers( (string)$inNeedle );
665
666		if ( !self::checkLength( $inStr ) ||
667			!self::checkLength( $inNeedle ) ) {
668			return self::tooLongError();
669		}
670
671		if ( $inNeedle === '' ) {
672			$inNeedle = ' ';
673		}
674
675		$pos = mb_strpos( $inStr, $inNeedle, min( (int)$inOffset, mb_strlen( $inStr ) ) );
676		if ( $pos === false ) {
677			$pos = '';
678		}
679
680		return $pos;
681	}
682
683	/**
684	 * {{#rpos: string | needle}}
685	 *
686	 * Finds last occurrence of "needle" in "string".
687	 *
688	 * Note: If the needle is an empty string, single space is used instead.
689	 * Note: If the needle is not found, -1 is returned.
690	 * @param Parser $parser
691	 * @param string $inStr
692	 * @param int|string $inNeedle
693	 * @return int|string
694	 */
695	public static function runRPos( Parser $parser, $inStr = '', $inNeedle = '' ) {
696		$inStr = $parser->killMarkers( (string)$inStr );
697		$inNeedle = $parser->killMarkers( (string)$inNeedle );
698
699		if ( !self::checkLength( $inStr ) ||
700			!self::checkLength( $inNeedle ) ) {
701			return self::tooLongError();
702		}
703
704		if ( $inNeedle === '' ) {
705			$inNeedle = ' ';
706		}
707
708		$pos = mb_strrpos( $inStr, $inNeedle );
709		if ( $pos === false ) {
710			$pos = -1;
711		}
712
713		return $pos;
714	}
715
716	/**
717	 * {{#sub: string | start | length }}
718	 *
719	 * Returns substring of "string" starting at "start" and having
720	 * "length" characters.
721	 *
722	 * Note: If length is zero, the rest of the input is returned.
723	 * Note: A negative value for "start" operates from the end of the
724	 *   "string".
725	 * Note: A negative value for "length" returns a string reduced in
726	 *   length by that amount.
727	 *
728	 * @param Parser $parser
729	 * @param string $inStr
730	 * @param int $inStart
731	 * @param int $inLength
732	 * @return string
733	 */
734	public static function runSub( Parser $parser, $inStr = '', $inStart = 0, $inLength = 0 ) {
735		$inStr = $parser->killMarkers( (string)$inStr );
736
737		if ( !self::checkLength( $inStr ) ) {
738			return self::tooLongError();
739		}
740
741		if ( (int)$inLength === 0 ) {
742			$result = mb_substr( $inStr, (int)$inStart );
743		} else {
744			$result = mb_substr( $inStr, (int)$inStart, (int)$inLength );
745		}
746
747		return $result;
748	}
749
750	/**
751	 * {{#count: string | substr }}
752	 *
753	 * Returns number of occurrences of "substr" in "string".
754	 *
755	 * Note: If "substr" is empty, a single space is used.
756	 *
757	 * @param Parser $parser
758	 * @param string $inStr
759	 * @param string $inSubStr
760	 * @return int|string
761	 */
762	public static function runCount( Parser $parser, $inStr = '', $inSubStr = '' ) {
763		$inStr = $parser->killMarkers( (string)$inStr );
764		$inSubStr = $parser->killMarkers( (string)$inSubStr );
765
766		if ( !self::checkLength( $inStr ) ||
767			!self::checkLength( $inSubStr ) ) {
768			return self::tooLongError();
769		}
770
771		if ( $inSubStr === '' ) {
772			$inSubStr = ' ';
773		}
774
775		$result = mb_substr_count( $inStr, $inSubStr );
776
777		return $result;
778	}
779
780	/**
781	 * {{#replace:string | from | to | limit }}
782	 *
783	 * Replaces each occurrence of "from" in "string" with "to".
784	 * At most "limit" replacements are performed.
785	 *
786	 * Note: Armored against replacements that would generate huge strings.
787	 * Note: If "from" is an empty string, single space is used instead.
788	 *
789	 * @param Parser $parser
790	 * @param string $inStr
791	 * @param string $inReplaceFrom
792	 * @param string $inReplaceTo
793	 * @param int $inLimit
794	 * @return mixed|string
795	 */
796	public static function runReplace( Parser $parser, $inStr = '',
797			$inReplaceFrom = '', $inReplaceTo = '', $inLimit = -1 ) {
798		global $wgPFStringLengthLimit;
799
800		$inStr = $parser->killMarkers( (string)$inStr );
801		$inReplaceFrom = $parser->killMarkers( (string)$inReplaceFrom );
802		$inReplaceTo = $parser->killMarkers( (string)$inReplaceTo );
803
804		if ( !self::checkLength( $inStr ) ||
805			!self::checkLength( $inReplaceFrom ) ||
806			!self::checkLength( $inReplaceTo ) ) {
807			return self::tooLongError();
808		}
809
810		if ( $inReplaceFrom === '' ) {
811			$inReplaceFrom = ' ';
812		}
813
814		// Precompute limit to avoid generating enormous string:
815		$diff = mb_strlen( $inReplaceTo ) - mb_strlen( $inReplaceFrom );
816		if ( $diff > 0 ) {
817			$limit = ( ( $wgPFStringLengthLimit - mb_strlen( $inStr ) ) / $diff ) + 1;
818		} else {
819			$limit = -1;
820		}
821
822		$inLimit = (int)$inLimit;
823		if ( $inLimit >= 0 ) {
824			if ( $limit > $inLimit || $limit == -1 ) {
825				$limit = $inLimit;
826			}
827		}
828
829		// Use regex to allow limit and handle UTF-8 correctly.
830		$inReplaceFrom = preg_quote( $inReplaceFrom, '/' );
831		$inReplaceTo = StringUtils::escapeRegexReplacement( $inReplaceTo );
832
833		$result = preg_replace( '/' . $inReplaceFrom . '/u',
834						$inReplaceTo, $inStr, $limit );
835
836		if ( !self::checkLength( $result ) ) {
837			return self::tooLongError();
838		}
839
840		return $result;
841	}
842
843	/**
844	 * {{#explode:string | delimiter | position | limit}}
845	 *
846	 * Breaks "string" into chunks separated by "delimiter" and returns the
847	 * chunk identified by "position".
848	 *
849	 * Note: Negative position can be used to specify tokens from the end.
850	 * Note: If the divider is an empty string, single space is used instead.
851	 * Note: Empty string is returned if there are not enough exploded chunks.
852	 *
853	 * @param Parser $parser
854	 * @param string $inStr
855	 * @param string $inDiv
856	 * @param int $inPos
857	 * @param int|null $inLim
858	 * @return string
859	 */
860	public static function runExplode(
861		Parser $parser, $inStr = '', $inDiv = '', $inPos = 0, $inLim = null
862	) {
863		$inStr = $parser->killMarkers( (string)$inStr );
864		$inDiv = $parser->killMarkers( (string)$inDiv );
865
866		if ( $inDiv === '' ) {
867			$inDiv = ' ';
868		}
869
870		if ( !self::checkLength( $inStr ) ||
871			!self::checkLength( $inDiv ) ) {
872			return self::tooLongError();
873		}
874
875		$inDiv = preg_quote( $inDiv, '/' );
876
877		$matches = preg_split( '/' . $inDiv . '/u', $inStr, $inLim );
878
879		if ( $inPos >= 0 && isset( $matches[$inPos] ) ) {
880			$result = $matches[$inPos];
881		} elseif ( $inPos < 0 && isset( $matches[count( $matches ) + $inPos] ) ) {
882			$result = $matches[count( $matches ) + $inPos];
883		} else {
884			$result = '';
885		}
886
887		return $result;
888	}
889
890	/**
891	 * {{#urldecode:string}}
892	 *
893	 * Decodes URL-encoded (like%20that) strings.
894	 *
895	 * @param Parser $parser
896	 * @param string $inStr
897	 * @return string
898	 */
899	public static function runUrlDecode( Parser $parser, $inStr = '' ) {
900		$inStr = $parser->killMarkers( (string)$inStr );
901		if ( !self::checkLength( $inStr ) ) {
902			return self::tooLongError();
903		}
904
905		return urldecode( $inStr );
906	}
907
908	/**
909	 * Take a PPNode (-ish thing), expand it, remove entities, and trim.
910	 *
911	 * For use when doing string comparisions, where user expects entities
912	 * to be equal for what they stand for (e.g. comparisions with {{PAGENAME}})
913	 *
914	 * @param PPNode|string $obj Thing to expand
915	 * @param PPFrame $frame
916	 * @param string|null &$trimExpanded Expanded and trimmed version of PPNode,
917	 *   but with char refs intact
918	 * @return string The trimmed, expanded and entity reference decoded version of the PPNode
919	 */
920	private static function decodeTrimExpand( $obj, PPFrame $frame, &$trimExpanded = null ) {
921		$expanded = $frame->expand( $obj );
922		$trimExpanded = trim( $expanded );
923		return trim( Sanitizer::decodeCharReferences( $expanded ) );
924	}
925
926	/**
927	 * @since 1.35
928	 * @param Language $language
929	 * @return ILanguageConverter
930	 */
931	private static function getLanguageConverter( Language $language ): ILanguageConverter {
932		return MediaWikiServices::getInstance()
933			->getLanguageConverterFactory()
934			->getLanguageConverter( $language );
935	}
936}
937