Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
16.18% covered (danger)
16.18%
39 / 241
29.17% covered (danger)
29.17%
7 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Utils
16.18% covered (danger)
16.18%
39 / 241
29.17% covered (danger)
29.17%
7 / 24
2955.35
0.00% covered (danger)
0.00%
0 / 1
 stripPHPNamespace
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isVoidElement
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cloneArray
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 lastUniChar
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 isUniWord
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 decodeURI
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 decodeURIComponent
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 extractExtBody
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 isValidOffset
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isValidDSR
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 normalizeNamespaceName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 decodeWtEntities
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 escapeWtEntities
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 escapeWt
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
42
 escapeHtml
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 entityEncodeAll
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 isProtocolValid
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getExtArgInfo
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 parseMediaDimensions
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
90
 validateMediaParam
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 bcp47ToMwCode
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 mwCodeToBcp47
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
182
 isBcp47CodeEqual
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 ensureValidUtf8
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Utils;
5
6use Psr\Log\LoggerInterface;
7use UtfNormal\Validator;
8use Wikimedia\Bcp47Code\Bcp47Code;
9use Wikimedia\Bcp47Code\Bcp47CodeValue;
10use Wikimedia\Parsoid\Config\Env;
11use Wikimedia\Parsoid\Config\SiteConfig;
12use Wikimedia\Parsoid\Core\DomSourceRange;
13use Wikimedia\Parsoid\Core\Sanitizer;
14use Wikimedia\Parsoid\NodeData\DataMw;
15use Wikimedia\Parsoid\NodeData\DataMwBody;
16use Wikimedia\Parsoid\NodeData\DataMwExtAttribs;
17use Wikimedia\Parsoid\Tokens\Token;
18use Wikimedia\Parsoid\Wikitext\Consts;
19
20/**
21 * This file contains general utilities for token transforms.
22 */
23class Utils {
24    /**
25     * Regular expression fragment for matching wikitext comments.
26     * Meant for inclusion in other regular expressions.
27     */
28    // Maintenance note: this is used in /x regexes so all whitespace and # should be escaped
29    public const COMMENT_REGEXP_FRAGMENT = '<!--(?>[\s\S]*?-->)';
30    /** Regular fragment for matching a wikitext comment */
31    public const COMMENT_REGEXP = '/' . self::COMMENT_REGEXP_FRAGMENT . '/';
32
33    public const COMMENT_OR_WS_REGEXP = '/^(\s|' . self::COMMENT_REGEXP_FRAGMENT . ')*$/D';
34
35    /**
36     * Strip PHP namespace from the fully qualified class name
37     * @param string $className
38     * @return string
39     */
40    public static function stripPHPNamespace( string $className ): string {
41        return preg_replace( '/.*\\\\/', '', $className );
42    }
43
44    /**
45     * Determine if the named tag is void (can not have content).
46     *
47     * @param string $name tag name
48     * @return bool
49     */
50    public static function isVoidElement( string $name ): bool {
51        return isset( Consts::$HTML['VoidTags'][$name] );
52    }
53
54    public static function cloneArray( array $arr ): array {
55        return array_map(
56            static function ( $val ) {
57                if ( is_array( $val ) ) {
58                    return self::cloneArray( $val );
59                } elseif ( is_object( $val ) ) {
60                    return clone $val;
61                } else {
62                    return $val;
63                }
64            },
65            $arr
66        );
67    }
68
69    /**
70     * Extract the last *unicode* character of the string.
71     * This might be more than one byte, if the last character
72     * is non-ASCII.
73     * @param string $str
74     * @param ?int $idx The index *after* the character to extract; defaults
75     *   to the length of $str, which will extract the last character in
76     *   $str.
77     * @return string
78     */
79    public static function lastUniChar( string $str, ?int $idx = null ): string {
80        if ( $idx === null ) {
81            $idx = strlen( $str );
82        } elseif ( $idx <= 0 || $idx > strlen( $str ) ) {
83            return '';
84        }
85        $c = $str[--$idx];
86        while ( ( ord( $c[0] ) & 0xC0 ) === 0x80 ) {
87            $c = $str[--$idx] . $c;
88        }
89        return $c;
90    }
91
92    /**
93     * Return true if the first character in $s is a unicode word character.
94     * @param string $s
95     * @return bool
96     */
97    public static function isUniWord( string $s ): bool {
98        return preg_match( '#^\w#u', $s ) === 1;
99    }
100
101    /**
102     * Percent-decode only valid UTF-8 characters, leaving other encoded bytes alone.
103     *
104     * Distinct from `decodeURIComponent` in that certain escapes are not decoded,
105     * matching the behavior of JavaScript's decodeURI().
106     *
107     * @see https://www.ecma-international.org/ecma-262/6.0/#sec-decodeuri-encodeduri
108     * @param string $s URI to be decoded
109     * @return string
110     */
111    public static function decodeURI( string $s ): string {
112        // Escape the '%' in sequences for the reserved characters, then use decodeURIComponent.
113        $s = preg_replace( '/%(?=2[346bcfBCF]|3[abdfABDF]|40)/', '%25', $s );
114        return self::decodeURIComponent( $s );
115    }
116
117    /**
118     * Percent-decode only valid UTF-8 characters, leaving other encoded bytes alone.
119     *
120     * @param string $s URI to be decoded
121     * @return string
122     */
123    public static function decodeURIComponent( string $s ): string {
124        // Most of the time we should have valid input
125        $ret = rawurldecode( $s );
126        if ( mb_check_encoding( $ret, 'UTF-8' ) ) {
127            return $ret;
128        }
129
130        // Extract each encoded character and decode it individually
131        return preg_replace_callback(
132            // phpcs:ignore Generic.Files.LineLength.TooLong
133            '/%[0-7][0-9A-F]|%[CD][0-9A-F]%[89AB][0-9A-F]|%E[0-9A-F](?:%[89AB][0-9A-F]){2}|%F[0-4](?:%[89AB][0-9A-F]){3}/i',
134            static function ( $match ) {
135                $ret = rawurldecode( $match[0] );
136                return mb_check_encoding( $ret, 'UTF-8' ) ? $ret : $match[0];
137            }, $s
138        );
139    }
140
141    /**
142     * Extract extension source from the token
143     *
144     * @param Token $token token
145     * @return string
146     */
147    public static function extractExtBody( Token $token ): string {
148        $src = $token->getAttributeV( 'source' );
149        $extTagOffsets = $token->dataParsoid->extTagOffsets;
150        '@phan-var \Wikimedia\Parsoid\Core\DomSourceRange $extTagOffsets';
151        return $extTagOffsets->stripTags( $src );
152    }
153
154    /**
155     * Helper function checks numeric values
156     *
157     * @param ?int $n checks parameters for numeric type and value zero or positive
158     * @return bool
159     */
160    private static function isValidOffset( ?int $n ): bool {
161        return $n !== null && $n >= 0;
162    }
163
164    /**
165     * Basic check if a DOM Source Range (DSR) is valid.
166     *
167     * Clarifications about the "basic validity checks":
168     * - Only checks for underflow, not for overflow.
169     * - Does not verify that start <= end
170     * - Does not verify that openWidth + endWidth <= end - start
171     *   (even so, the values might be invalid because of content)
172     * These would be overkill for our purposes. Given how DSR computation
173     * works in thie codebase, the real scenarios we care about are
174     * non-null / non-negative values since that can happen.
175     *
176     * @param ?DomSourceRange $dsr DSR source range values
177     * @param bool $all Also check the widths of the container tag
178     * @return bool
179     */
180    public static function isValidDSR(
181        ?DomSourceRange $dsr, bool $all = false
182    ): bool {
183        return $dsr !== null &&
184            self::isValidOffset( $dsr->start ) &&
185            self::isValidOffset( $dsr->end ) &&
186            ( !$all || (
187                self::isValidOffset( $dsr->openWidth ) &&
188                self::isValidOffset( $dsr->closeWidth )
189              )
190            );
191    }
192
193    /**
194     * Cannonicalizes a namespace name.
195     *
196     * @param string $name Non-normalized namespace name.
197     * @return string
198     */
199    public static function normalizeNamespaceName( string $name ): string {
200        return strtr( mb_strtolower( $name ), ' ', '_' );
201    }
202
203    /**
204     * Decode HTML5 entities in wikitext.
205     *
206     * NOTE that wikitext only allows semicolon-terminated entities, while
207     * HTML allows a number of "legacy" entities to be decoded without
208     * a terminating semicolon.  This function deliberately does not
209     * decode these HTML-only entity forms.
210     *
211     * @param string $text
212     * @return string
213     */
214    public static function decodeWtEntities( string $text ): string {
215        // Note that HTML5 allows semicolon-less entities which
216        // wikitext does not: in wikitext all entities must end in a
217        // semicolon.
218        // By normalizing before decoding, this routine deliberately
219        // does not decode entity references which are invalid in wikitext
220        // (mostly because they decode to invalid codepoints).
221        return Sanitizer::decodeCharReferences(
222            Sanitizer::normalizeCharReferences( $text )
223        );
224    }
225
226    /**
227     * Entity-escape anything that would decode to a valid wikitext entity.
228     *
229     * Note that HTML5 allows certain "semicolon-less" entities, like
230     * `&para`; these aren't allowed in wikitext and won't be escaped
231     * by this function.
232     *
233     * @param string $text
234     * @return string
235     */
236    public static function escapeWtEntities( string $text ): string {
237        // We just want to encode ampersands that precede valid entities.
238        // (And note that semicolon-less entities aren't valid wikitext.)
239        return preg_replace_callback( '/&[#0-9a-zA-Z\x80-\xff]+;/', function ( $match ) {
240            $m = $match[0];
241            $decodedChar = self::decodeWtEntities( $m );
242            if ( $decodedChar !== $m ) {
243                // Escape the ampersand
244                return '&amp;' . substr( $m, 1 );
245            } else {
246                // Not an entity, just return the string
247                return $m;
248            }
249        }, $text );
250    }
251
252    /**
253     * Ensure that the given literal string is safe to parse as wikitext.
254     * See wfEscapeWikiText() in core.
255     */
256    public static function escapeWt( string $input ): string {
257        static $repl = null, $repl2 = null, $repl3 = null, $repl4 = null;
258        if ( $repl === null ) {
259            $repl = [
260                '"' => '&#34;', '&' => '&#38;', "'" => '&#39;', '<' => '&#60;',
261                '=' => '&#61;', '>' => '&#62;', '[' => '&#91;', ']' => '&#93;',
262                '{' => '&#123;', '|' => '&#124;', '}' => '&#125;',
263                ';' => '&#59;', // a token inside language converter brackets
264                '!!' => '&#33;!', // a token inside table context
265                "\n!" => "\n&#33;", "\r!" => "\r&#33;", // a token inside table context
266                "\n#" => "\n&#35;", "\r#" => "\r&#35;",
267                "\n*" => "\n&#42;", "\r*" => "\r&#42;",
268                "\n:" => "\n&#58;", "\r:" => "\r&#58;",
269                "\n " => "\n&#32;", "\r " => "\r&#32;",
270                "\n\n" => "\n&#10;", "\r\n" => "&#13;\n",
271                "\n\r" => "\n&#13;", "\r\r" => "\r&#13;",
272                "\n\t" => "\n&#9;", "\r\t" => "\r&#9;", // "\n\t\n" is treated like "\n\n"
273                "\n----" => "\n&#45;---", "\r----" => "\r&#45;---",
274                '__' => '_&#95;', '://' => '&#58;//',
275                '~~~' => '~~&#126;', // protect from PST, just to be safe(r)
276            ];
277
278            $magicLinks = [ 'ISBN', 'PMID', 'RFC' ];
279            // We have to catch everything "\s" matches in PCRE
280            foreach ( $magicLinks as $magic ) {
281                $repl["$magic "] = "$magic&#32;";
282                $repl["$magic\t"] = "$magic&#9;";
283                $repl["$magic\r"] = "$magic&#13;";
284                $repl["$magic\n"] = "$magic&#10;";
285                $repl["$magic\f"] = "$magic&#12;";
286            }
287            // Additionally escape the following characters at the beginning of the
288            // string, in case they merge to form tokens when spliced into a
289            // string.  Tokens like -{ {{ [[ {| etc are already escaped because
290            // the second character is escaped above, but the following tokens
291            // are handled here: |+ |- __FOO__ ~~~
292            $repl3 = [
293                '+' => '&#43;', '-' => '&#45;', '_' => '&#95;', '~' => '&#126;',
294            ];
295            // Similarly, protect the following characters at the end of the
296            // string, which could turn form the start of `__FOO__` or `~~~~`
297            // A trailing newline could also form the unintended start of a
298            // paragraph break if it is glued to a newline in the following
299            // context.
300            $repl4 = [
301                '_' => '&#95;', '~' => '&#126;',
302                "\n" => "&#10;", "\r" => "&#13;",
303                "\t" => "&#9;", // "\n\t\n" is treated like "\n\n"
304            ];
305
306            // And handle protocols that don't use "://"
307            $urlProtocols = [
308                'bitcoin:', 'geo:', 'magnet:', 'mailto:', 'matrix:', 'news:',
309                'sip:', 'sips:', 'sms:', 'tel:', 'urn:', 'xmpp:',
310            ];
311            $repl2 = [];
312            foreach ( $urlProtocols as $prot ) {
313                $repl2[] = preg_quote( substr( $prot, 0, -1 ), '/' );
314            }
315            $repl2 = '/\b(' . implode( '|', $repl2 ) . '):/i';
316        }
317        // Tell phan that $repl2, $repl3 and $repl4 will also be non-null here
318        '@phan-var string $repl2';
319        '@phan-var string $repl3';
320        '@phan-var string $repl4';
321        // This will also stringify input in case it's not a string
322        $text = substr( strtr( "\n$input", $repl ), 1 );
323        if ( $text === '' ) {
324            return $text;
325        }
326        $first = strtr( $text[0], $repl3 ); // protect first character
327        if ( strlen( $text ) > 1 ) {
328            $text = $first . substr( $text, 1, -1 ) .
329                  strtr( substr( $text, -1 ), $repl4 ); // protect last character
330        } else {
331            // special case for single-character strings
332            $text = strtr( $first, $repl4 ); // protect last character
333        }
334        $text = preg_replace( $repl2, '$1&#58;', $text );
335        return $text;
336    }
337
338    /**
339     * Convert special characters to HTML entities
340     *
341     * @param string $s
342     * @return string
343     */
344    public static function escapeHtml( string $s ): string {
345        // Only encodes five characters: " ' & < >
346        $s = htmlspecialchars( $s, ENT_QUOTES | ENT_HTML5 );
347        $s = str_replace( "\u{0338}", '&#x338;', $s );
348        return $s;
349    }
350
351    /**
352     * Encode all characters as entity references.  This is done to make
353     * characters safe for wikitext (regardless of whether they are
354     * HTML-safe). Typically only called with single-codepoint strings.
355     * @param string $s
356     * @return string
357     */
358    public static function entityEncodeAll( string $s ): string {
359        // This is Unicode aware.
360        static $conventions = [
361            // We always use at least two characters for the hex code
362            '&#x0;' => '&#x00;', '&#x1;' => '&#x01;', '&#x2;' => '&#x02;', '&#x3;' => '&#x03;',
363            '&#x4;' => '&#x04;', '&#x5;' => '&#x05;', '&#x6;' => '&#x06;', '&#x7;' => '&#x07;',
364            '&#x8;' => '&#x08;', '&#x9;' => '&#x09;', '&#xA;' => '&#x0A;', '&#xB;' => '&#x0B;',
365            '&#xC;' => '&#x0C;', '&#xD;' => '&#x0D;', '&#xE;' => '&#x0E;', '&#xF;' => '&#x0F;',
366            // By convention we use &nbsp; where possible
367            '&#xA0;' => '&nbsp;',
368        ];
369
370        return strtr( mb_encode_numericentity(
371            $s, [ 0, 0x10ffff, 0, ~0 ], 'utf-8', true
372        ), $conventions );
373    }
374
375    /**
376     * Determine whether the protocol of a link is potentially valid. Use the
377     * environment's per-wiki config to do so.
378     *
379     * @param mixed $linkTarget
380     * @param Env $env
381     * @return bool
382     */
383    public static function isProtocolValid( $linkTarget, Env $env ): bool {
384        $siteConf = $env->getSiteConfig();
385        if ( is_string( $linkTarget ) ) {
386            return $siteConf->hasValidProtocol( $linkTarget );
387        } else {
388            return true;
389        }
390    }
391
392    /**
393     * Get argument information for an extension tag token.
394     *
395     * @param Token $extToken
396     * @return DataMw
397     */
398    public static function getExtArgInfo( Token $extToken ): DataMw {
399        $name = $extToken->getAttributeV( 'name' );
400        $options = $extToken->getAttributeV( 'options' );
401        $defaultDataMw = new DataMw( [
402            'name' => $name,
403            // Back-compat w/ existing DOM spec output: ensure 'extAttribs'
404            // exists even if there are no attributes.
405            'extAttribs' => new DataMwExtAttribs,
406        ] );
407        foreach ( TokenUtils::kvToHash( $options ) as $name => $value ) {
408            // Explicit cast to string is needed here, since a numeric
409            // attribute name will get converted to 'int' when it is used
410            // as an array key.
411            $defaultDataMw->setExtAttrib( (string)$name, $value );
412        }
413        $extTagOffsets = $extToken->dataParsoid->extTagOffsets;
414        if ( $extTagOffsets->closeWidth !== 0 ) {
415            // If not self-closing...
416            $defaultDataMw->body = new DataMwBody(
417                self::extractExtBody( $extToken ),
418            );
419        }
420        return $defaultDataMw;
421    }
422
423    /**
424     * Parse media dimensions
425     *
426     * @param SiteConfig $siteConfig
427     * @param string $str media dimension string to parse
428     * @param bool $onlyOne If set, returns null if multiple dimenstions are present
429     * @param bool $localized Defaults to false; set to true if the $str
430     *   has already been matched against `img_width` to localize the `px`
431     *   suffix.
432     * @return ?array{x:int,y?:int,bogusPx:bool}
433     */
434    public static function parseMediaDimensions(
435        SiteConfig $siteConfig, string $str, bool $onlyOne = false,
436        bool $localized = false
437    ): ?array {
438        if ( !$localized ) {
439            $getOption = $siteConfig->getMediaPrefixParameterizedAliasMatcher();
440            $bits = $getOption( $str );
441            $normalizedBit0 = $bits ? mb_strtolower( trim( $bits['k'] ) ) : null;
442            if ( $normalizedBit0 === 'img_width' ) {
443                $str = $bits['v'];
444            }
445        }
446        $dimensions = null;
447        // We support a trailing 'px' here for historical reasons
448        // (T15500, T53628, T207032)
449        if ( preg_match( '/^(\d*)(?:x(\d+))?\s*(px\s*)?$/D', $str, $match ) ) {
450            $dimensions = [ 'x' => null, 'y' => null, 'bogusPx' => false ];
451            if ( !empty( $match[1] ) ) {
452                $dimensions['x'] = intval( $match[1], 10 );
453            }
454            if ( !empty( $match[2] ) ) {
455                if ( $onlyOne ) {
456                    return null;
457                }
458                $dimensions['y'] = intval( $match[2], 10 );
459            }
460            if ( !empty( $match[3] ) ) {
461                $dimensions['bogusPx'] = true;
462            }
463        }
464        return $dimensions;
465    }
466
467    /**
468     * Validate media parameters
469     * More generally, this is defined by the media handler in core
470     *
471     * @param ?int $num
472     * @return bool
473     */
474    public static function validateMediaParam( ?int $num ): bool {
475        return $num !== null && $num > 0;
476    }
477
478    /**
479     * Convert BCP-47-compliant language code to MediaWiki-internal code.
480     *
481     * This is a temporary back-compatibility hack; Parsoid should be
482     * using BCP 47 strings or Bcp47Code objects in all its external APIs.
483     * Try to avoid using it, though: there's no guarantee
484     * that this mapping will remain in sync with upstream.
485     *
486     * @param string|Bcp47Code $code BCP-47 language code
487     * @return string MediaWiki-internal language code
488     */
489    public static function bcp47ToMwCode( $code ): string {
490        // This map is dumped from
491        // LanguageCode::NON_STANDARD_LANGUAGE_CODE_MAPPING in core, but
492        // with keys and values swapped and BCP-47 codes lowercased:
493        //
494        //   array_flip(array_map(strtolower,
495        //       LanguageCode::NON_STANDARD_LANGUAGE_CODE_MAPPING))
496        //
497        // Hopefully we will be able to deprecate and remove this from
498        // Parsoid quickly enough that keeping it in sync with upstream
499        // is not an issue.
500        static $MAP = [
501            "cbk" => "cbk-zam",
502            "de-x-formal" => "de-formal",
503            "egl" => "eml",
504            "en-x-rtl" => "en-rtl",
505            "es-x-formal" => "es-formal",
506            "hu-x-formal" => "hu-formal",
507            "jv-x-bms" => "map-bms",
508            "ro-cyrl-md" => "mo",
509            "nrf" => "nrm",
510            "nl-x-informal" => "nl-informal",
511            "nap-x-tara" => "roa-tara",
512            "en-simple" => "simple",
513            "sr-cyrl" => "sr-ec",
514            "sr-latn" => "sr-el",
515            "zh-hans-cn" => "zh-cn",
516            "zh-hans-sg" => "zh-sg",
517            "zh-hans-my" => "zh-my",
518            "zh-hant-tw" => "zh-tw",
519            "zh-hant-hk" => "zh-hk",
520            "zh-hant-mo" => "zh-mo",
521        ];
522        if ( $code instanceof Bcp47Code ) {
523            $code = $code->toBcp47Code();
524        }
525        $code = strtolower( $code ); // All MW-internal codes are lowercase
526        return $MAP[$code] ?? $code;
527    }
528
529    /**
530     * Convert MediaWiki-internal language code to a BCP-47-compliant
531     * language code suitable for including in HTML.
532     *
533     * This is a temporary back-compatibility hack, needed for compatibility
534     * when running in standalone mode with MediaWiki Action APIs which expose
535     * internal language codes.  These APIs should eventually be improved
536     * so that they also expose BCP-47 compliant codes, which can then be
537     * used directly by Parsoid without conversion.  But until that day
538     * comes, this function will paper over the differences.
539     *
540     * Note that MediaWiki-internal Language objects implement Bcp47Code,
541     * so we can transition interfaces which currently take a string code
542     * to pass a Language object instead; that will make this method
543     * effectively a no-op and avoid the issue of upstream sync of the
544     * mapping table.
545     *
546     * @param string|Bcp47Code $code MediaWiki-internal language code or object
547     * @param bool $strict If true, this code will log a deprecation message
548     *  or fail if a MediaWiki-internal language code is passed.
549     * @param ?LoggerInterface $warnLogger A deprecation warning will be
550     *   emitted on $warnLogger if $strict is true and a string-valued
551     *   MediaWiki-internal language code is passed; otherwise an exception
552     *   will be thrown.
553     * @return Bcp47Code BCP-47 language code.
554     * @see LanguageCode::bcp47()
555     */
556    public static function mwCodeToBcp47(
557        $code, bool $strict = false, ?LoggerInterface $warnLogger = null
558    ): Bcp47Code {
559        if ( $code instanceof Bcp47Code ) {
560            return $code;
561        }
562        if ( $strict ) {
563            $msg = "Use of string-valued BCP-47 codes is deprecated.";
564            if ( defined( 'MW_PHPUNIT_TEST' ) || defined( 'MW_PARSER_TEST' ) ) {
565                // Always throw an error if running tests
566                throw new \Error( $msg );
567            }
568            if ( $warnLogger ) {
569                $warnLogger->warning( $msg );
570            } else {
571                // Strict mode requested but no deprecation logger provided
572                throw new \Error( $msg );
573            }
574        }
575        // This map is dumped from
576        // LanguageCode::getNonstandardLanguageCodeMapping() in core.
577        // Hopefully we will be able to deprecate and remove this method
578        // from Parsoid quickly enough that keeping it in sync with upstream
579        // will not be an issue.
580        static $MAP = [
581            "als" => "gsw",
582            "bat-smg" => "sgs",
583            "be-x-old" => "be-tarask",
584            "fiu-vro" => "vro",
585            "roa-rup" => "rup",
586            "zh-classical" => "lzh",
587            "zh-min-nan" => "nan",
588            "zh-yue" => "yue",
589            "cbk-zam" => "cbk",
590            "de-formal" => "de-x-formal",
591            "eml" => "egl",
592            "en-rtl" => "en-x-rtl",
593            "es-formal" => "es-x-formal",
594            "hu-formal" => "hu-x-formal",
595            "map-bms" => "jv-x-bms",
596            "mo" => "ro-Cyrl-MD",
597            "nrm" => "nrf",
598            "nl-informal" => "nl-x-informal",
599            "roa-tara" => "nap-x-tara",
600            "simple" => "en-simple",
601            "sr-ec" => "sr-Cyrl",
602            "sr-el" => "sr-Latn",
603            "zh-cn" => "zh-Hans-CN",
604            "zh-sg" => "zh-Hans-SG",
605            "zh-my" => "zh-Hans-MY",
606            "zh-tw" => "zh-Hant-TW",
607            "zh-hk" => "zh-Hant-HK",
608            "zh-mo" => "zh-Hant-MO",
609        ];
610        $code = $MAP[$code] ?? $code;
611        // The rest of this code is copied verbatim from LanguageCode::bcp47()
612        // in core.
613        $codeSegment = explode( '-', $code );
614        $codeBCP = [];
615        foreach ( $codeSegment as $segNo => $seg ) {
616            // when previous segment is x, it is a private segment and should be lc
617            if ( $segNo > 0 && strtolower( $codeSegment[( $segNo - 1 )] ) == 'x' ) {
618                $codeBCP[$segNo] = strtolower( $seg );
619            // ISO 3166 country code
620            } elseif ( ( strlen( $seg ) == 2 ) && ( $segNo > 0 ) ) {
621                $codeBCP[$segNo] = strtoupper( $seg );
622            // ISO 15924 script code
623            } elseif ( ( strlen( $seg ) == 4 ) && ( $segNo > 0 ) ) {
624                $codeBCP[$segNo] = ucfirst( strtolower( $seg ) );
625            // Use lowercase for other cases
626            } else {
627                $codeBCP[$segNo] = strtolower( $seg );
628            }
629        }
630        return new Bcp47CodeValue( implode( '-', $codeBCP ) );
631    }
632
633    /**
634     * BCP 47 codes are case-insensitive, so this helper does a "proper"
635     * comparison of Bcp47Code objects.
636     * @param Bcp47Code $a
637     * @param Bcp47Code $b
638     * @return bool true iff $a and $b represent the same language
639     */
640    public static function isBcp47CodeEqual( Bcp47Code $a, Bcp47Code $b ): bool {
641        return strcasecmp( $a->toBcp47Code(), $b->toBcp47Code() ) === 0;
642    }
643
644    /**
645     * Validates string for utf encoding. If check fails, string is being fixed and warning is being thrown,
646     * if logger was provided
647     *
648     * @param string &$input reference to input, input is being fixed in case of invalid character.
649     * @param LoggerInterface|null $warnLogger if provided, log is being generated
650     */
651    public static function ensureValidUtf8( string &$input, ?LoggerInterface $warnLogger = null ): void {
652        // this is faster than Validator::cleanUp
653        if ( mb_check_encoding( $input, 'UTF-8' ) ) {
654            return;
655        }
656
657        $warnLogger?->warning( "Malformed utf-8 characters detected. Replacing." );
658
659        $input = Validator::cleanUp( $input );
660    }
661}