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