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