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