Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.92% |
94 / 96 |
|
92.31% |
12 / 13 |
CRAP | |
0.00% |
0 / 1 |
| MagicWordArray | |
97.92% |
94 / 96 |
|
92.31% |
12 / 13 |
47 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| add | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getHash | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
| getBaseRegex | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
14 | |||
| getRegex | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| getRegexStart | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| getVariableStartToEndRegex | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| getNames | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| parseMatch | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
6.40 | |||
| matchVariableStartToEnd | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| matchStartToEnd | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| matchAndRemove | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| matchStartAndRemove | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\Parser; |
| 8 | |
| 9 | use LogicException; |
| 10 | use MediaWiki\MediaWikiServices; |
| 11 | |
| 12 | /** |
| 13 | * Class for handling an array of magic words |
| 14 | * |
| 15 | * See docs/magicword.md. |
| 16 | * |
| 17 | * @since 1.11 |
| 18 | * @ingroup Parser |
| 19 | */ |
| 20 | class MagicWordArray { |
| 21 | |
| 22 | /** @var string[] */ |
| 23 | public $names = []; |
| 24 | private MagicWordFactory $factory; |
| 25 | |
| 26 | /** @var array<int,array<string,string>>|null */ |
| 27 | private $hash; |
| 28 | |
| 29 | /** @var string[]|null */ |
| 30 | private $baseRegex; |
| 31 | |
| 32 | /** @var string[]|null */ |
| 33 | private $regex; |
| 34 | |
| 35 | /** |
| 36 | * @param string[] $names |
| 37 | * @param MagicWordFactory|null $factory |
| 38 | */ |
| 39 | public function __construct( $names = [], ?MagicWordFactory $factory = null ) { |
| 40 | $this->names = $names; |
| 41 | $this->factory = $factory ?? MediaWikiServices::getInstance()->getMagicWordFactory(); |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * Add a magic word by name |
| 46 | * |
| 47 | * @param string $name |
| 48 | */ |
| 49 | public function add( $name ): void { |
| 50 | $this->names[] = $name; |
| 51 | $this->hash = $this->baseRegex = $this->regex = null; |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Get a 2-d hashtable for this array |
| 56 | * |
| 57 | * @return array<int,array<string,string>> |
| 58 | */ |
| 59 | public function getHash(): array { |
| 60 | if ( $this->hash === null ) { |
| 61 | $this->hash = [ 0 => [], 1 => [] ]; |
| 62 | foreach ( $this->names as $name ) { |
| 63 | $magic = $this->factory->get( $name ); |
| 64 | $case = intval( $magic->isCaseSensitive() ); |
| 65 | foreach ( $magic->getSynonyms() as $syn ) { |
| 66 | if ( !$case ) { |
| 67 | $syn = $this->factory->getContentLanguage()->lc( $syn ); |
| 68 | } |
| 69 | $this->hash[$case][$syn] = $name; |
| 70 | } |
| 71 | } |
| 72 | } |
| 73 | return $this->hash; |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * Get the base regex |
| 78 | * |
| 79 | * @internal For use in {@see Parser} only |
| 80 | * @param bool $capture Set to false to suppress the capture groups, |
| 81 | * which can cause unexpected conflicts when this regexp is embedded in |
| 82 | * other regexps with similar constructs. |
| 83 | * @param string $delimiter The delimiter which will be used for the |
| 84 | * eventual regexp. |
| 85 | * @return array<int,string> |
| 86 | */ |
| 87 | public function getBaseRegex( bool $capture = true, string $delimiter = '/' ): array { |
| 88 | if ( $capture && $delimiter === '/' && $this->baseRegex !== null ) { |
| 89 | return $this->baseRegex; |
| 90 | } |
| 91 | $regex = [ 0 => [], 1 => [] ]; |
| 92 | foreach ( $this->names as $name ) { |
| 93 | $magic = $this->factory->get( $name ); |
| 94 | $case = $magic->isCaseSensitive() ? 1 : 0; |
| 95 | $quotedSynonyms = []; |
| 96 | foreach ( $magic->getSynonyms() as $syn ) { |
| 97 | $quotedSynonyms[] = preg_quote( $syn, $delimiter ); |
| 98 | } |
| 99 | $quotedSynonyms = implode( '|', $quotedSynonyms ); |
| 100 | if ( $capture ) { |
| 101 | if ( substr_count( $quotedSynonyms, '\$1' ) > 1 ) { |
| 102 | // We know synonyms are exclusive to each other, and so are their parameter |
| 103 | // value placeholders that will become (.*?) subpatterns later. |
| 104 | $quotedSynonyms = "(?|$quotedSynonyms)"; |
| 105 | } |
| 106 | // Group name must start with a non-digit in PCRE 8.34+ |
| 107 | $quotedSynonyms = "(?P<_$name>$quotedSynonyms)"; |
| 108 | } |
| 109 | $regex[$case][] = $quotedSynonyms; |
| 110 | } |
| 111 | '@phan-var array<int,string[]> $regex'; |
| 112 | foreach ( $regex as $case => &$re ) { |
| 113 | $re = count( $re ) ? implode( '|', $re ) : '(?!)'; |
| 114 | if ( !$case ) { |
| 115 | $re = "(?i:{$re})"; |
| 116 | } |
| 117 | } |
| 118 | '@phan-var array<int,string> $regex'; |
| 119 | |
| 120 | if ( $capture && $delimiter === '/' ) { |
| 121 | $this->baseRegex = $regex; |
| 122 | } |
| 123 | return $regex; |
| 124 | } |
| 125 | |
| 126 | /** |
| 127 | * Get an unanchored regex that does not match parameters |
| 128 | * |
| 129 | * @return array<int,string> |
| 130 | */ |
| 131 | private function getRegex(): array { |
| 132 | if ( $this->regex === null ) { |
| 133 | $this->regex = []; |
| 134 | $base = $this->getBaseRegex( true, '/' ); |
| 135 | foreach ( $base as $case => $re ) { |
| 136 | $this->regex[$case] = "/$re/JS"; |
| 137 | } |
| 138 | // As a performance optimization, turn on unicode mode only for |
| 139 | // case-insensitive matching. |
| 140 | $this->regex[0] .= 'u'; |
| 141 | } |
| 142 | return $this->regex; |
| 143 | } |
| 144 | |
| 145 | /** |
| 146 | * Get a regex anchored to the start of the string that does not match parameters |
| 147 | * |
| 148 | * @return array<int,string> |
| 149 | */ |
| 150 | private function getRegexStart(): array { |
| 151 | $newRegex = []; |
| 152 | $base = $this->getBaseRegex( true, '/' ); |
| 153 | foreach ( $base as $case => $re ) { |
| 154 | $newRegex[$case] = "/^(?:$re)/JS"; |
| 155 | } |
| 156 | // As a performance optimization, turn on unicode mode only for |
| 157 | // case-insensitive matching. |
| 158 | $newRegex[0] .= 'u'; |
| 159 | return $newRegex; |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * Get an anchored regex for matching variables with parameters |
| 164 | * |
| 165 | * @return array<int,string> |
| 166 | */ |
| 167 | private function getVariableStartToEndRegex(): array { |
| 168 | $newRegex = []; |
| 169 | $base = $this->getBaseRegex( true, '/' ); |
| 170 | foreach ( $base as $case => $re ) { |
| 171 | $newRegex[$case] = str_replace( '\$1', '(.*?)', "/^(?:$re)$/JS" ); |
| 172 | } |
| 173 | // As a performance optimization, turn on unicode mode only for |
| 174 | // case-insensitive matching. |
| 175 | $newRegex[0] .= 'u'; |
| 176 | return $newRegex; |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * @since 1.20 |
| 181 | * @return string[] |
| 182 | */ |
| 183 | public function getNames() { |
| 184 | return $this->names; |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * Parse a match array from preg_match |
| 189 | * |
| 190 | * @param array<string|int,string> $matches |
| 191 | * @return array{0:string,1:string,2:string|false} Tuple of (magic word ID, magic word alias, parameter value), |
| 192 | * where the latter is instead false if there is no parameter value. |
| 193 | */ |
| 194 | private function parseMatch( array $matches ): array { |
| 195 | $magicName = null; |
| 196 | foreach ( $matches as $key => $match ) { |
| 197 | if ( $magicName !== null ) { |
| 198 | // The structure we found at this point is [ …, |
| 199 | // 'a_magicWordName' => 'matchedSynonym', |
| 200 | // n => 'matchedSynonym (again)', |
| 201 | // n + 1 => 'parameterValue', |
| 202 | // … ] |
| 203 | return [ $magicName, $match, $matches[$key + 1] ?? false ]; |
| 204 | } |
| 205 | // Skip the initial full match and any non-matching group |
| 206 | if ( $match !== '' && $key !== 0 ) { |
| 207 | if ( !str_starts_with( $key, '_' ) ) { |
| 208 | throw new LogicException( 'Unexpected group name' ); |
| 209 | } |
| 210 | $magicName = substr( $key, 1 ); |
| 211 | } |
| 212 | } |
| 213 | throw new LogicException( 'Unexpected $m array with no match' ); |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * Match some text, with parameter capture |
| 218 | * |
| 219 | * @param string $text |
| 220 | * @return (string|false)[] Magic word name in the first element and the parameter in the second |
| 221 | * element. Both elements are false if there was no match. |
| 222 | */ |
| 223 | public function matchVariableStartToEnd( $text ): array { |
| 224 | $regexes = $this->getVariableStartToEndRegex(); |
| 225 | foreach ( $regexes as $regex ) { |
| 226 | $m = []; |
| 227 | if ( preg_match( $regex, $text, $m ) ) { |
| 228 | [ $id, $alias, $param ] = $this->parseMatch( $m ); |
| 229 | return [ $id, $param ]; |
| 230 | } |
| 231 | } |
| 232 | return [ false, false ]; |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * Match some text, without parameter capture |
| 237 | * |
| 238 | * @see MagicWord::matchStartToEnd |
| 239 | * @param string $text |
| 240 | * @return string|false The magic word name, or false if there was no capture |
| 241 | */ |
| 242 | public function matchStartToEnd( $text ) { |
| 243 | $hash = $this->getHash(); |
| 244 | if ( isset( $hash[1][$text] ) ) { |
| 245 | return $hash[1][$text]; |
| 246 | } |
| 247 | $lc = $this->factory->getContentLanguage()->lc( $text ); |
| 248 | return $hash[0][$lc] ?? false; |
| 249 | } |
| 250 | |
| 251 | /** |
| 252 | * Return an associative array for all items that match. |
| 253 | * |
| 254 | * Cannot be used for magic words with parameters. |
| 255 | * Removes the matched items from the input string (passed by reference) |
| 256 | * |
| 257 | * @see MagicWord::matchAndRemove |
| 258 | * @param string &$text |
| 259 | * @param bool $returnAlias When true, returns the localized alias as |
| 260 | * the value in the returned array. When false (the default), the |
| 261 | * value in the returned array is `false`. |
| 262 | * @return array<string,string|false> Keyed by magic word ID |
| 263 | */ |
| 264 | public function matchAndRemove( &$text, bool $returnAlias = false ): array { |
| 265 | $found = []; |
| 266 | $regexes = $this->getRegex(); |
| 267 | $res = preg_replace_callback( $regexes, function ( $m ) use ( &$found, $returnAlias ) { |
| 268 | [ $name, $alias, $param ] = $this->parseMatch( $m ); |
| 269 | $found[$name] = $returnAlias ? $alias : $param; |
| 270 | return ''; |
| 271 | }, $text ); |
| 272 | // T321234: Don't try to fix old revisions with broken UTF-8, just return $text as is |
| 273 | if ( $res !== null ) { |
| 274 | $text = $res; |
| 275 | } |
| 276 | return $found; |
| 277 | } |
| 278 | |
| 279 | /** |
| 280 | * Return the ID of the magic word at the start of $text, and remove |
| 281 | * the prefix from $text. |
| 282 | * |
| 283 | * Does not match parameters. |
| 284 | * |
| 285 | * @see MagicWord::matchStartAndRemove |
| 286 | * @param string &$text Unmodified if no match is found. |
| 287 | * @return string|false False if no match is found. |
| 288 | */ |
| 289 | public function matchStartAndRemove( &$text ) { |
| 290 | $regexes = $this->getRegexStart(); |
| 291 | foreach ( $regexes as $regex ) { |
| 292 | if ( preg_match( $regex, $text, $m ) ) { |
| 293 | [ $id, ] = $this->parseMatch( $m ); |
| 294 | if ( strlen( $m[0] ) >= strlen( $text ) ) { |
| 295 | $text = ''; |
| 296 | } else { |
| 297 | $text = substr( $text, strlen( $m[0] ) ); |
| 298 | } |
| 299 | return $id; |
| 300 | } |
| 301 | } |
| 302 | return false; |
| 303 | } |
| 304 | } |