Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.50% covered (success)
97.50%
78 / 80
90.91% covered (success)
90.91%
10 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
LanguageNameUtils
98.73% covered (success)
98.73%
78 / 79
90.91% covered (success)
90.91%
10 / 11
41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 isSupportedLanguage
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 isValidCode
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 isValidBuiltInCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isKnownLanguageTag
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 getLanguageNames
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 getLanguageNamesUncached
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
15
 getLanguageName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFileName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getMessagesFileName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getJsonMessagesFileName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Language;
8
9use InvalidArgumentException;
10use MediaWiki\Config\ServiceOptions;
11use MediaWiki\HookContainer\HookContainer;
12use MediaWiki\HookContainer\HookRunner;
13use MediaWiki\Languages\Data\Names;
14use MediaWiki\MainConfigNames;
15use MediaWiki\Title\TitleParser;
16use Wikimedia\ObjectCache\BagOStuff;
17use Wikimedia\ObjectCache\HashBagOStuff;
18
19/**
20 * A service that provides utilities to do with language names and codes.
21 *
22 * See https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation for more information.
23 *
24 * @since 1.34
25 * @ingroup Language
26 */
27class LanguageNameUtils {
28    /**
29     * Return autonyms in getLanguageName(s).
30     */
31    public const AUTONYMS = null;
32
33    /**
34     * Return all known languages in getLanguageName(s).
35     */
36    public const ALL = 'all';
37
38    /**
39     * Return in getLanguageName(s) only the languages that are defined by MediaWiki.
40     */
41    public const DEFINED = 'mw';
42
43    /**
44     * Return in getLanguageName(s) only the languages for which we have at least some localisation.
45     */
46    public const SUPPORTED = 'mwfile';
47
48    /** @var ServiceOptions */
49    private $options;
50
51    /**
52     * Cache for language names
53     * @var HashBagOStuff|null
54     */
55    private $languageNameCache;
56
57    /**
58     * Cache for validity of language codes
59     * @var array
60     */
61    private $validCodeCache = [];
62
63    /**
64     * @internal For use by ServiceWiring
65     */
66    public const CONSTRUCTOR_OPTIONS = [
67        MainConfigNames::ExtraLanguageNames,
68        MainConfigNames::UsePigLatinVariant,
69        MainConfigNames::UseXssLanguage,
70    ];
71
72    /** @var HookRunner */
73    private $hookRunner;
74
75    public function __construct( ServiceOptions $options, HookContainer $hookContainer ) {
76        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
77        $this->options = $options;
78        $this->hookRunner = new HookRunner( $hookContainer );
79    }
80
81    /**
82     * Checks whether any localisation is available for that language tag in MediaWiki
83     * (MessagesXx.php or xx.json exists).
84     *
85     * @param string $code Language tag (in lower case)
86     * @return bool Whether language is supported
87     */
88    public function isSupportedLanguage( string $code ): bool {
89        if ( !$this->isValidBuiltInCode( $code ) ) {
90            return false;
91        }
92
93        if ( $code === 'qqq' ) {
94            // Special code for internal use, not supported even though there is a qqq.json
95            return false;
96        }
97        if (
98            $code === 'en-x-piglatin' &&
99            !$this->options->get( MainConfigNames::UsePigLatinVariant )
100        ) {
101            // Suppress Pig Latin unless explicitly enabled.
102            return false;
103        }
104
105        return is_readable( $this->getMessagesFileName( $code ) ) ||
106            is_readable( $this->getJsonMessagesFileName( $code ) );
107    }
108
109    /**
110     * Returns true if a language code string is of a valid form, whether it exists.
111     * This includes codes which are used solely for customisation via the MediaWiki namespace.
112     *
113     * @param string $code
114     *
115     * @return bool False if the language code contains dangerous characters, e.g, HTML special
116     *  characters or characters that are illegal in MediaWiki titles.
117     */
118    public function isValidCode( string $code ): bool {
119        if ( !isset( $this->validCodeCache[$code] ) ) {
120            // People think language codes are HTML-safe, so enforce it. Ideally, we should only
121            // allow a-zA-Z0-9- but .+ and other chars are often used for {{int:}} hacks.  See bugs
122            // T39564, T39587, T38938.
123            $this->validCodeCache[$code] =
124                // Protect against path traversal
125                strcspn( $code, ":/\\\000&<>'\"" ) === strlen( $code ) &&
126                !preg_match( TitleParser::getTitleInvalidRegex(), $code ) &&
127                // libicu sets ULOC_FULLNAME_CAPACITY to 157; stay comfortably lower
128                strlen( $code ) <= 128;
129        }
130        return $this->validCodeCache[$code];
131    }
132
133    /**
134     * Returns true if a language code is of a valid form for the purposes of internal customisation
135     * of MediaWiki, via Messages*.php or *.json.
136     *
137     * @param string $code
138     * @return bool
139     */
140    public function isValidBuiltInCode( string $code ): bool {
141        return (bool)preg_match( '/^[a-z0-9-]{2,128}$/', $code );
142    }
143
144    /**
145     * Returns true if a language code is an IETF tag known to MediaWiki.
146     *
147     * @param string $tag
148     *
149     * @return bool
150     */
151    public function isKnownLanguageTag( string $tag ): bool {
152        // Quick escape for invalid input to avoid exceptions down the line when code tries to
153        // process tags which are not valid at all.
154        if ( !$this->isValidBuiltInCode( $tag ) ) {
155            return false;
156        }
157
158        if ( isset( Names::NAMES[$tag] ) || $this->getLanguageName( $tag, $tag ) !== '' ) {
159            return true;
160        }
161
162        return false;
163    }
164
165    /**
166     * Get an array of language names, indexed by code.
167     *
168     * @param null|string $inLanguage Code of language in which to return the names
169     *   Use self::AUTONYMS for autonyms (native names)
170     * @param string $include One of:
171     *   self::ALL All available languages
172     *   self::DEFINED Only if the language is defined in MediaWiki or wgExtraLanguageNames
173     *     (default)
174     *   self::SUPPORTED Only if the language is in self::DEFINED *and* has a message file
175     * @return array Language code => language name (sorted by key)
176     */
177    public function getLanguageNames( $inLanguage = self::AUTONYMS, $include = self::DEFINED ) {
178        if ( $inLanguage !== self::AUTONYMS ) {
179            $inLanguage = LanguageCode::replaceDeprecatedCodes( LanguageCode::bcp47ToInternal( $inLanguage ) );
180        }
181        $cacheKey = $inLanguage === self::AUTONYMS ? 'null' : $inLanguage;
182        $cacheKey .= ":$include";
183        if ( !$this->languageNameCache ) {
184            $this->languageNameCache = new HashBagOStuff( [ 'maxKeys' => 20 ] );
185        }
186
187        return $this->languageNameCache->getWithSetCallback(
188            $cacheKey,
189            BagOStuff::TTL_INDEFINITE,
190            function () use ( $inLanguage, $include ) {
191                return $this->getLanguageNamesUncached( $inLanguage, $include );
192            }
193        );
194    }
195
196    /**
197     * Uncached helper for getLanguageNames.
198     *
199     * @param null|string $inLanguage As getLanguageNames
200     * @param string $include As getLanguageNames
201     * @return array Language code => language name (sorted by key)
202     */
203    private function getLanguageNamesUncached( $inLanguage, $include ) {
204        // If passed an invalid language code to use, fallback to en
205        if ( $inLanguage !== self::AUTONYMS && !$this->isValidCode( $inLanguage ) ) {
206            $inLanguage = 'en';
207        }
208
209        $names = [];
210
211        if ( $inLanguage !== self::AUTONYMS ) {
212            # TODO: also include for self::AUTONYMS, when this code is more efficient
213            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
214            $this->hookRunner->onLanguageGetTranslatedLanguageNames( $names, $inLanguage );
215        }
216
217        $mwNames = $this->options->get( MainConfigNames::ExtraLanguageNames ) + Names::NAMES;
218        if ( !$this->options->get( MainConfigNames::UsePigLatinVariant ) ) {
219            // Suppress Pig Latin unless explicitly enabled.
220            unset( $mwNames['en-x-piglatin'] );
221        }
222        if ( $this->options->get( MainConfigNames::UseXssLanguage ) ) {
223            $mwNames['x-xss'] = 'fake xss language (see $wgUseXssLanguage)';
224        }
225
226        foreach ( $mwNames as $mwCode => $mwName ) {
227            # - Prefer own MediaWiki native name when not using the hook
228            # - For other names just add if not added through the hook
229            if ( $mwCode === $inLanguage || !isset( $names[$mwCode] ) ) {
230                $names[$mwCode] = $mwName;
231            }
232        }
233
234        if ( $include === self::ALL ) {
235            ksort( $names );
236            return $names;
237        }
238
239        $returnMw = [];
240        $coreCodes = array_keys( $mwNames );
241        foreach ( $coreCodes as $coreCode ) {
242            $returnMw[$coreCode] = $names[$coreCode];
243        }
244
245        if ( $include === self::SUPPORTED ) {
246            $namesMwFile = [];
247            # We do this using a foreach over the codes instead of a directory loop so that messages
248            # files in extensions will work correctly.
249            foreach ( $returnMw as $code => $value ) {
250                if ( is_readable( $this->getMessagesFileName( $code ) ) ||
251                    is_readable( $this->getJsonMessagesFileName( $code ) )
252                ) {
253                    $namesMwFile[$code] = $names[$code];
254                }
255            }
256
257            ksort( $namesMwFile );
258            return $namesMwFile;
259        }
260
261        ksort( $returnMw );
262        # self::DEFINED option; default if it's not one of the other two options
263        # (self::ALL/self::SUPPORTED)
264        return $returnMw;
265    }
266
267    /**
268     * @param string $code The code of the language for which to get the name
269     * @param null|string $inLanguage Code of language in which to return the name (self::AUTONYMS
270     *   for autonyms)
271     * @param string $include See getLanguageNames(), except this function defaults to self::ALL instead of
272     *   self::DEFINED
273     * @return string Language name or empty
274     */
275    public function getLanguageName( $code, $inLanguage = self::AUTONYMS, $include = self::ALL ) {
276        $code = LanguageCode::replaceDeprecatedCodes( LanguageCode::bcp47ToInternal( $code ) );
277        $array = $this->getLanguageNames( $inLanguage, $include );
278        return $array[$code] ?? '';
279    }
280
281    /**
282     * Get the name of a file for a certain language code.
283     *
284     * @param string $prefix Prepend this to the filename
285     * @param string $code Language code
286     * @param string $suffix Append this to the filename
287     * @return string $prefix . $mangledCode . $suffix
288     */
289    public function getFileName( $prefix, $code, $suffix = '.php' ) {
290        if ( !$this->isValidBuiltInCode( $code ) ) {
291            throw new InvalidArgumentException( "Invalid language code \"$code\"" );
292        }
293
294        return $prefix . str_replace( '-', '_', ucfirst( $code ) ) . $suffix;
295    }
296
297    /**
298     * @param string $code
299     * @return string
300     */
301    public function getMessagesFileName( $code ) {
302        global $IP;
303        $file = $this->getFileName( "$IP/languages/messages/Messages", $code, '.php' );
304        $this->hookRunner->onLanguage__getMessagesFileName( $code, $file );
305        return $file;
306    }
307
308    /**
309     * @param string $code
310     * @return string
311     */
312    public function getJsonMessagesFileName( $code ) {
313        global $IP;
314
315        if ( !$this->isValidBuiltInCode( $code ) ) {
316            throw new InvalidArgumentException( "Invalid language code \"$code\"" );
317        }
318
319        return "$IP/languages/i18n/$code.json";
320    }
321}
322
323/** @deprecated class alias since 1.45 */
324class_alias( LanguageNameUtils::class, 'MediaWiki\\Languages\\LanguageNameUtils' );