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