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