Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
17.74% covered (danger)
17.74%
33 / 186
21.43% covered (danger)
21.43%
6 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
Utils
17.74% covered (danger)
17.74%
33 / 186
21.43% covered (danger)
21.43%
6 / 28
2718.93
0.00% covered (danger)
0.00%
0 / 1
 stripParsoidIdPrefix
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 stripNamespace
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isParsoidObjectId
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
 recursiveClone
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 clone
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 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
 phpURLEncode
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
 escapeHtml
100.00% covered (success)
100.00%
1 / 1
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 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 parseMediaDimensions
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 validateMediaParam
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getStar
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isLinkTrail
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
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Utils;
5
6use Psr\Log\LoggerInterface;
7use Wikimedia\Bcp47Code\Bcp47Code;
8use Wikimedia\Bcp47Code\Bcp47CodeValue;
9use Wikimedia\Parsoid\Config\Env;
10use Wikimedia\Parsoid\Core\DomSourceRange;
11use Wikimedia\Parsoid\Core\Sanitizer;
12use Wikimedia\Parsoid\NodeData\DataMw;
13use Wikimedia\Parsoid\Tokens\Token;
14use Wikimedia\Parsoid\Wikitext\Consts;
15
16/**
17 * This file contains general utilities for token transforms.
18 */
19class Utils {
20    /**
21     * Regular expression fragment for matching wikitext comments.
22     * Meant for inclusion in other regular expressions.
23     */
24    // Maintenance note: this is used in /x regexes so all whitespace and # should be escaped
25    public const COMMENT_REGEXP_FRAGMENT = '<!--(?>[\s\S]*?-->)';
26    /** Regular fragment for matching a wikitext comment */
27    public const COMMENT_REGEXP = '/' . self::COMMENT_REGEXP_FRAGMENT . '/';
28
29    /**
30     * Strip Parsoid id prefix from aboutID
31     *
32     * @param string $aboutId aboud ID string
33     * @return string
34     */
35    public static function stripParsoidIdPrefix( string $aboutId ): string {
36        // 'mwt' is the prefix used for new ids
37        return preg_replace( '/^#?mwt/', '', $aboutId );
38    }
39
40    /**
41     * Strip PHP namespace from the fully qualified class name
42     * @param string $className
43     * @return string
44     */
45    public static function stripNamespace( string $className ): string {
46        return preg_replace( '/.*\\\\/', '', $className );
47    }
48
49    /**
50     * Check for Parsoid id prefix in an aboutID string
51     *
52     * @param string $aboutId aboud ID string
53     * @return bool
54     */
55    public static function isParsoidObjectId( string $aboutId ): bool {
56        // 'mwt' is the prefix used for new ids
57        return str_starts_with( $aboutId, '#mwt' );
58    }
59
60    /**
61     * Determine if the named tag is void (can not have content).
62     *
63     * @param string $name tag name
64     * @return bool
65     */
66    public static function isVoidElement( string $name ): bool {
67        return isset( Consts::$HTML['VoidTags'][$name] );
68    }
69
70    /**
71     * recursive deep clones helper function
72     *
73     * @param object $el object
74     * @return object
75     */
76    private static function recursiveClone( $el ) {
77        return self::clone( $el, true );
78    }
79
80    /**
81     * Deep clones by default.
82     * @param object|array $obj arrays or plain objects
83     *    Tokens or DOM nodes shouldn't be passed in.
84     *
85     *    CAVEAT: It looks like debugging methods pass in arrays
86     *    that can have DOM nodes. So, for debugging purposes,
87     *    we handle top-level DOM nodes or DOM nodes embedded in arrays
88     *    But, this will miserably fail if an object embeds a DOM node.
89     *
90     * @param bool $deepClone
91     * @param bool $debug
92     * @return object|array
93     */
94    public static function clone( $obj, $deepClone = true, $debug = false ) {
95        if ( $debug ) {
96            if ( $obj instanceof \DOMNode ) {
97                return $obj->cloneNode( $deepClone );
98            }
99            if ( is_array( $obj ) ) {
100                if ( $deepClone ) {
101                    return array_map(
102                        static function ( $o ) {
103                            return Utils::clone( $o, true, true );
104                        },
105                        $obj
106                    );
107                } else {
108                    return $obj; // Copy-on-write cloning
109                }
110            }
111        }
112
113        if ( !$deepClone && is_object( $obj ) ) {
114            return clone $obj;
115        }
116
117        // FIXME, see T161647
118        // This will fail if $obj is (or embeds) a DOMNode
119        return unserialize( serialize( $obj ) );
120    }
121
122    /**
123     * Extract the last *unicode* character of the string.
124     * This might be more than one byte, if the last character
125     * is non-ASCII.
126     * @param string $str
127     * @param ?int $idx The index *after* the character to extract; defaults
128     *   to the length of $str, which will extract the last character in
129     *   $str.
130     * @return string
131     */
132    public static function lastUniChar( string $str, ?int $idx = null ): string {
133        if ( $idx === null ) {
134            $idx = strlen( $str );
135        } elseif ( $idx <= 0 || $idx > strlen( $str ) ) {
136            return '';
137        }
138        $c = $str[--$idx];
139        while ( ( ord( $c ) & 0xC0 ) === 0x80 ) {
140            $c = $str[--$idx] . $c;
141        }
142        return $c;
143    }
144
145    /**
146     * Return true if the first character in $s is a unicode word character.
147     * @param string $s
148     * @return bool
149     */
150    public static function isUniWord( string $s ): bool {
151        return preg_match( '#^\w#u', $s ) === 1;
152    }
153
154    /**
155     * This should not be used.
156     * @param string $txt URL to encode using PHP encoding
157     * @return string
158     */
159    public static function phpURLEncode( $txt ) {
160        // @phan-suppress-previous-line PhanPluginNeverReturnMethod
161        throw new \BadMethodCallException( 'Use urlencode( $txt ) instead' );
162    }
163
164    /**
165     * Percent-decode only valid UTF-8 characters, leaving other encoded bytes alone.
166     *
167     * Distinct from `decodeURIComponent` in that certain escapes are not decoded,
168     * matching the behavior of JavaScript's decodeURI().
169     *
170     * @see https://www.ecma-international.org/ecma-262/6.0/#sec-decodeuri-encodeduri
171     * @param string $s URI to be decoded
172     * @return string
173     */
174    public static function decodeURI( string $s ): string {
175        // Escape the '%' in sequences for the reserved characters, then use decodeURIComponent.
176        $s = preg_replace( '/%(?=2[346bcfBCF]|3[abdfABDF]|40)/', '%25', $s );
177        return self::decodeURIComponent( $s );
178    }
179
180    /**
181     * Percent-decode only valid UTF-8 characters, leaving other encoded bytes alone.
182     *
183     * @param string $s URI to be decoded
184     * @return string
185     */
186    public static function decodeURIComponent( string $s ): string {
187        // Most of the time we should have valid input
188        $ret = rawurldecode( $s );
189        if ( mb_check_encoding( $ret, 'UTF-8' ) ) {
190            return $ret;
191        }
192
193        // Extract each encoded character and decode it individually
194        return preg_replace_callback(
195            // phpcs:ignore Generic.Files.LineLength.TooLong
196            '/%[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',
197            static function ( $match ) {
198                $ret = rawurldecode( $match[0] );
199                return mb_check_encoding( $ret, 'UTF-8' ) ? $ret : $match[0];
200            }, $s
201        );
202    }
203
204    /**
205     * Extract extension source from the token
206     *
207     * @param Token $token token
208     * @return string
209     */
210    public static function extractExtBody( Token $token ): string {
211        $src = $token->getAttributeV( 'source' );
212        $extTagOffsets = $token->dataParsoid->extTagOffsets;
213        '@phan-var \Wikimedia\Parsoid\Core\DomSourceRange $extTagOffsets';
214        return $extTagOffsets->stripTags( $src );
215    }
216
217    /**
218     * Helper function checks numeric values
219     *
220     * @param ?int $n checks parameters for numeric type and value zero or positive
221     * @return bool
222     */
223    private static function isValidOffset( ?int $n ): bool {
224        return $n !== null && $n >= 0;
225    }
226
227    /**
228     * Basic check if a DOM Source Range (DSR) is valid.
229     *
230     * Clarifications about the "basic validity checks":
231     * - Only checks for underflow, not for overflow.
232     * - Does not verify that start <= end
233     * - Does not verify that openWidth + endWidth <= end - start
234     *   (even so, the values might be invalid because of content)
235     * These would be overkill for our purposes. Given how DSR computation
236     * works in thie codebase, the real scenarios we care about are
237     * non-null / non-negative values since that can happen.
238     *
239     * @param ?DomSourceRange $dsr DSR source range values
240     * @param bool $all Also check the widths of the container tag
241     * @return bool
242     */
243    public static function isValidDSR(
244        ?DomSourceRange $dsr, bool $all = false
245    ): bool {
246        return $dsr !== null &&
247            self::isValidOffset( $dsr->start ) &&
248            self::isValidOffset( $dsr->end ) &&
249            ( !$all || (
250                self::isValidOffset( $dsr->openWidth ) &&
251                self::isValidOffset( $dsr->closeWidth )
252              )
253            );
254    }
255
256    /**
257     * Cannonicalizes a namespace name.
258     *
259     * @param string $name Non-normalized namespace name.
260     * @return string
261     */
262    public static function normalizeNamespaceName( string $name ): string {
263        return strtr( mb_strtolower( $name ), ' ', '_' );
264    }
265
266    /**
267     * Decode HTML5 entities in wikitext.
268     *
269     * NOTE that wikitext only allows semicolon-terminated entities, while
270     * HTML allows a number of "legacy" entities to be decoded without
271     * a terminating semicolon.  This function deliberately does not
272     * decode these HTML-only entity forms.
273     *
274     * @param string $text
275     * @return string
276     */
277    public static function decodeWtEntities( string $text ): string {
278        // Note that HTML5 allows semicolon-less entities which
279        // wikitext does not: in wikitext all entities must end in a
280        // semicolon.
281        // By normalizing before decoding, this routine deliberately
282        // does not decode entity references which are invalid in wikitext
283        // (mostly because they decode to invalid codepoints).
284        return Sanitizer::decodeCharReferences(
285            Sanitizer::normalizeCharReferences( $text )
286        );
287    }
288
289    /**
290     * Entity-escape anything that would decode to a valid wikitext entity.
291     *
292     * Note that HTML5 allows certain "semicolon-less" entities, like
293     * `&para`; these aren't allowed in wikitext and won't be escaped
294     * by this function.
295     *
296     * @param string $text
297     * @return string
298     */
299    public static function escapeWtEntities( string $text ): string {
300        // We just want to encode ampersands that precede valid entities.
301        // (And note that semicolon-less entities aren't valid wikitext.)
302        return preg_replace_callback( '/&[#0-9a-zA-Z\x80-\xff]+;/', function ( $match ) {
303            $m = $match[0];
304            $decodedChar = self::decodeWtEntities( $m );
305            if ( $decodedChar !== $m ) {
306                // Escape the ampersand
307                return '&amp;' . substr( $m, 1 );
308            } else {
309                // Not an entity, just return the string
310                return $m;
311            }
312        }, $text );
313    }
314
315    /**
316     * Convert special characters to HTML entities
317     *
318     * @param string $s
319     * @return string
320     */
321    public static function escapeHtml( string $s ): string {
322        // Only encodes five characters: " ' & < >
323        return htmlspecialchars( $s, ENT_QUOTES | ENT_HTML5 );
324    }
325
326    /**
327     * Encode all characters as entity references.  This is done to make
328     * characters safe for wikitext (regardless of whether they are
329     * HTML-safe). Typically only called with single-codepoint strings.
330     * @param string $s
331     * @return string
332     */
333    public static function entityEncodeAll( string $s ): string {
334        // This is Unicode aware.
335        static $conventions = [
336            // We always use at least two characters for the hex code
337            '&#x0;' => '&#x00;', '&#x1;' => '&#x01;', '&#x2;' => '&#x02;', '&#x3;' => '&#x03;',
338            '&#x4;' => '&#x04;', '&#x5;' => '&#x05;', '&#x6;' => '&#x06;', '&#x7;' => '&#x07;',
339            '&#x8;' => '&#x08;', '&#x9;' => '&#x09;', '&#xA;' => '&#x0A;', '&#xB;' => '&#x0B;',
340            '&#xC;' => '&#x0C;', '&#xD;' => '&#x0D;', '&#xE;' => '&#x0E;', '&#xF;' => '&#x0F;',
341            // By convention we use &nbsp; where possible
342            '&#xA0;' => '&nbsp;',
343        ];
344
345        return strtr( mb_encode_numericentity(
346            $s, [ 0, 0x10ffff, 0, ~0 ], 'utf-8', true
347        ), $conventions );
348    }
349
350    /**
351     * Determine whether the protocol of a link is potentially valid. Use the
352     * environment's per-wiki config to do so.
353     *
354     * @param mixed $linkTarget
355     * @param Env $env
356     * @return bool
357     */
358    public static function isProtocolValid( $linkTarget, Env $env ): bool {
359        $siteConf = $env->getSiteConfig();
360        if ( is_string( $linkTarget ) ) {
361            return $siteConf->hasValidProtocol( $linkTarget );
362        } else {
363            return true;
364        }
365    }
366
367    /**
368     * Get argument information for an extension tag token.
369     *
370     * @param Token $extToken
371     * @return DataMw
372     */
373    public static function getExtArgInfo( Token $extToken ): DataMw {
374        $name = $extToken->getAttributeV( 'name' );
375        $options = $extToken->getAttributeV( 'options' );
376        $defaultDataMw = new DataMw( [
377            'name' => $name,
378            // T367616: 'attrs' should be renamed to 'extAttrs'
379            'attrs' => (object)TokenUtils::kvToHash( $options ),
380        ] );
381        $extTagOffsets = $extToken->dataParsoid->extTagOffsets;
382        if ( $extTagOffsets->closeWidth !== 0 ) {
383            // If not self-closing...
384            $defaultDataMw->body = (object)[
385                'extsrc' => self::extractExtBody( $extToken ),
386            ];
387        }
388        return $defaultDataMw;
389    }
390
391    /**
392     * Parse media dimensions
393     *
394     * @param string $str media dimension string to parse
395     * @param bool $onlyOne If set, returns null if multiple dimenstions are present
396     * @return ?array{x:int,y?:int,bogusPx:bool}
397     */
398    public static function parseMediaDimensions(
399        string $str, bool $onlyOne = false
400    ): ?array {
401        $dimensions = null;
402        // We support a trailing 'px' here for historical reasons
403        // (T15500, T53628, T207032)
404        if ( preg_match( '/^(\d*)(?:x(\d+))?\s*(px\s*)?$/D', $str, $match ) ) {
405            $dimensions = [ 'x' => null, 'y' => null, 'bogusPx' => false ];
406            if ( !empty( $match[1] ) ) {
407                $dimensions['x'] = intval( $match[1], 10 );
408            }
409            if ( !empty( $match[2] ) ) {
410                if ( $onlyOne ) {
411                    return null;
412                }
413                $dimensions['y'] = intval( $match[2], 10 );
414            }
415            if ( !empty( $match[3] ) ) {
416                $dimensions['bogusPx'] = true;
417            }
418        }
419        return $dimensions;
420    }
421
422    /**
423     * Validate media parameters
424     * More generally, this is defined by the media handler in core
425     *
426     * @param ?int $num
427     * @return bool
428     */
429    public static function validateMediaParam( ?int $num ): bool {
430        return $num !== null && $num > 0;
431    }
432
433    /**
434     * FIXME: Is this needed??
435     *
436     * Extract content in a backwards compatible way
437     *
438     * @param object $revision
439     * @return object
440     */
441    public static function getStar( $revision ) {
442        // @phan-suppress-previous-line PhanPluginNeverReturnMethod
443        /*
444        $content = $revision;
445        if ( $revision && isset( $revision->slots ) ) {
446            $content = $revision->slots->main;
447        }
448        return $content;
449        */
450        throw new \BadMethodCallException( "This method shouldn't be needed. " .
451            "But, port this if you really need it." );
452    }
453
454    /**
455     * This regex was generated by running through *all unicode characters* and
456     * testing them against *all regexes* for linktrails in a default MW install.
457     * We had to treat it a little bit, here's what we changed:
458     *
459     * 1. A-Z, though allowed in Walloon, is disallowed.
460     * 2. '"', though allowed in Chuvash, is disallowed.
461     * 3. '-', though allowed in Icelandic (possibly due to a bug), is disallowed.
462     * 4. '1', though allowed in Lak (possibly due to a bug), is disallowed.
463     */
464    // phpcs:disable Generic.Files.LineLength.TooLong
465    public static $linkTrailRegex =
466        '/^[^\0-`{÷ĀĈ-ČĎĐĒĔĖĚĜĝĠ-ĪĬ-įIJĴ-ĹĻ-ĽĿŀŅņʼnŊŌŎŏŒŔŖ-ŘŜŝŠŤŦŨŪ-ŬŮŲ-ŴŶŸ' .
467        'ſ-ǤǦǨǪ-Ǯǰ-ȗȜ-ȞȠ-ɘɚ-ʑʓ-ʸʽ-̂̄-΅·΋΍΢Ϗ-ЯѐѝѠѢѤѦѨѪѬѮѰѲѴѶѸѺ-ѾҀ-҃҅-ҐҒҔҕҘҚҜ-ҠҤ-ҪҬҭҰҲ' .
468        'Ҵ-ҶҸҹҼ-ҿӁ-ӗӚ-ӜӞӠ-ӢӤӦӪ-ӲӴӶ-ՠֈ-׏׫-ؠً-ٳٵ-ٽٿ-څڇ-ڗڙ-ڨڪ-ڬڮڰ-ڽڿ-ۅۈ-ۊۍ-۔ۖ-਀਄਋-਎਑਒' .
469        '਩਱਴਷਺਻਽੃-੆੉੊੎-੘੝੟-੯ੴ-჏ჱ-ẼẾ-\x{200b}\x{200d}-‒—-‗‚‛”--\x{fffd}]+$/D';
470    // phpcs:enable Generic.Files.LineLength.TooLong
471
472    /**
473     * Check whether some text is a valid link trail.
474     *
475     * @param string $text
476     * @return bool
477     */
478    public static function isLinkTrail( string $text ): bool {
479        return $text !== '' && preg_match( self::$linkTrailRegex, $text );
480    }
481
482    /**
483     * Convert BCP-47-compliant language code to MediaWiki-internal code.
484     *
485     * This is a temporary back-compatibility hack; Parsoid should be
486     * using BCP 47 strings or Bcp47Code objects in all its external APIs.
487     * Try to avoid using it, though: there's no guarantee
488     * that this mapping will remain in sync with upstream.
489     *
490     * @param string|Bcp47Code $code BCP-47 language code
491     * @return string MediaWiki-internal language code
492     */
493    public static function bcp47ToMwCode( $code ): string {
494        // This map is dumped from
495        // LanguageCode::NON_STANDARD_LANGUAGE_CODE_MAPPING in core, but
496        // with keys and values swapped and BCP-47 codes lowercased:
497        //
498        //   array_flip(array_map(strtolower,
499        //       LanguageCode::NON_STANDARD_LANGUAGE_CODE_MAPPING))
500        //
501        // Hopefully we will be able to deprecate and remove this from
502        // Parsoid quickly enough that keeping it in sync with upstream
503        // is not an issue.
504        static $MAP = [
505            "cbk" => "cbk-zam",
506            "de-x-formal" => "de-formal",
507            "egl" => "eml",
508            "en-x-rtl" => "en-rtl",
509            "es-x-formal" => "es-formal",
510            "hu-x-formal" => "hu-formal",
511            "jv-x-bms" => "map-bms",
512            "ro-cyrl-md" => "mo",
513            "nrf" => "nrm",
514            "nl-x-informal" => "nl-informal",
515            "nap-x-tara" => "roa-tara",
516            "en-simple" => "simple",
517            "sr-cyrl" => "sr-ec",
518            "sr-latn" => "sr-el",
519            "zh-hans-cn" => "zh-cn",
520            "zh-hans-sg" => "zh-sg",
521            "zh-hans-my" => "zh-my",
522            "zh-hant-tw" => "zh-tw",
523            "zh-hant-hk" => "zh-hk",
524            "zh-hant-mo" => "zh-mo",
525        ];
526        if ( $code instanceof Bcp47Code ) {
527            $code = $code->toBcp47Code();
528        }
529        $code = strtolower( $code ); // All MW-internal codes are lowercase
530        return $MAP[$code] ?? $code;
531    }
532
533    /**
534     * Convert MediaWiki-internal language code to a BCP-47-compliant
535     * language code suitable for including in HTML.
536     *
537     * This is a temporary back-compatibility hack, needed for compatibility
538     * when running in standalone mode with MediaWiki Action APIs which expose
539     * internal language codes.  These APIs should eventually be improved
540     * so that they also expose BCP-47 compliant codes, which can then be
541     * used directly by Parsoid without conversion.  But until that day
542     * comes, this function will paper over the differences.
543     *
544     * Note that MediaWiki-internal Language objects implement Bcp47Code,
545     * so we can transition interfaces which currently take a string code
546     * to pass a Language object instead; that will make this method
547     * effectively a no-op and avoid the issue of upstream sync of the
548     * mapping table.
549     *
550     * @param string|Bcp47Code $code MediaWiki-internal language code or object
551     * @param bool $strict If true, this code will log a deprecation message
552     *  or fail if a MediaWiki-internal language code is passed.
553     * @param ?LoggerInterface $warnLogger A deprecation warning will be
554     *   emitted on $warnLogger if $strict is true and a string-valued
555     *   MediaWiki-internal language code is passed; otherwise an exception
556     *   will be thrown.
557     * @return Bcp47Code BCP-47 language code.
558     * @see LanguageCode::bcp47()
559     */
560    public static function mwCodeToBcp47(
561        $code, bool $strict = false, ?LoggerInterface $warnLogger = null
562    ): Bcp47Code {
563        if ( $code instanceof Bcp47Code ) {
564            return $code;
565        }
566        if ( $strict ) {
567            $msg = "Use of string-valued BCP-47 codes is deprecated.";
568            if ( defined( 'MW_PHPUNIT_TEST' ) || defined( 'MW_PARSER_TEST' ) ) {
569                // Always throw an error if running tests
570                throw new \Error( $msg );
571            }
572            if ( $warnLogger ) {
573                $warnLogger->warning( $msg );
574            } else {
575                // Strict mode requested but no deprecation logger provided
576                throw new \Error( $msg );
577            }
578        }
579        // This map is dumped from
580        // LanguageCode::getNonstandardLanguageCodeMapping() in core.
581        // Hopefully we will be able to deprecate and remove this method
582        // from Parsoid quickly enough that keeping it in sync with upstream
583        // will not be an issue.
584        static $MAP = [
585            "als" => "gsw",
586            "bat-smg" => "sgs",
587            "be-x-old" => "be-tarask",
588            "fiu-vro" => "vro",
589            "roa-rup" => "rup",
590            "zh-classical" => "lzh",
591            "zh-min-nan" => "nan",
592            "zh-yue" => "yue",
593            "cbk-zam" => "cbk",
594            "de-formal" => "de-x-formal",
595            "eml" => "egl",
596            "en-rtl" => "en-x-rtl",
597            "es-formal" => "es-x-formal",
598            "hu-formal" => "hu-x-formal",
599            "map-bms" => "jv-x-bms",
600            "mo" => "ro-Cyrl-MD",
601            "nrm" => "nrf",
602            "nl-informal" => "nl-x-informal",
603            "roa-tara" => "nap-x-tara",
604            "simple" => "en-simple",
605            "sr-ec" => "sr-Cyrl",
606            "sr-el" => "sr-Latn",
607            "zh-cn" => "zh-Hans-CN",
608            "zh-sg" => "zh-Hans-SG",
609            "zh-my" => "zh-Hans-MY",
610            "zh-tw" => "zh-Hant-TW",
611            "zh-hk" => "zh-Hant-HK",
612            "zh-mo" => "zh-Hant-MO",
613        ];
614        $code = $MAP[$code] ?? $code;
615        // The rest of this code is copied verbatim from LanguageCode::bcp47()
616        // in core.
617        $codeSegment = explode( '-', $code );
618        $codeBCP = [];
619        foreach ( $codeSegment as $segNo => $seg ) {
620            // when previous segment is x, it is a private segment and should be lc
621            if ( $segNo > 0 && strtolower( $codeSegment[( $segNo - 1 )] ) == 'x' ) {
622                $codeBCP[$segNo] = strtolower( $seg );
623            // ISO 3166 country code
624            } elseif ( ( strlen( $seg ) == 2 ) && ( $segNo > 0 ) ) {
625                $codeBCP[$segNo] = strtoupper( $seg );
626            // ISO 15924 script code
627            } elseif ( ( strlen( $seg ) == 4 ) && ( $segNo > 0 ) ) {
628                $codeBCP[$segNo] = ucfirst( strtolower( $seg ) );
629            // Use lowercase for other cases
630            } else {
631                $codeBCP[$segNo] = strtolower( $seg );
632            }
633        }
634        return new Bcp47CodeValue( implode( '-', $codeBCP ) );
635    }
636
637    /**
638     * BCP 47 codes are case-insensitive, so this helper does a "proper"
639     * comparison of Bcp47Code objects.
640     * @param Bcp47Code $a
641     * @param Bcp47Code $b
642     * @return bool true iff $a and $b represent the same language
643     */
644    public static function isBcp47CodeEqual( Bcp47Code $a, Bcp47Code $b ): bool {
645        return strcasecmp( $a->toBcp47Code(), $b->toBcp47Code() ) === 0;
646    }
647}