Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.18% covered (warning)
70.18%
353 / 503
31.43% covered (danger)
31.43%
11 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
LocalisationCache
70.32% covered (warning)
70.32%
353 / 502
31.43% covered (danger)
31.43%
11 / 35
1375.16
0.00% covered (danger)
0.00%
0 / 1
 getStoreFromConf
50.00% covered (danger)
50.00%
12 / 24
0.00% covered (danger)
0.00%
0 / 1
22.50
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isMergeableKey
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getItem
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 getSubitem
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getSubitemWithSource
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getSubitemList
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 loadItem
73.91% covered (warning)
73.91%
17 / 23
0.00% covered (danger)
0.00%
0 / 1
14.56
 getFromStore
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 getFallbackCodes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 loadSubitem
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 isExpired
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
9
 getExpiredReason
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initLanguage
69.05% covered (warning)
69.05%
29 / 42
0.00% covered (danger)
0.00%
0 / 1
31.86
 initShallowFallback
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 readPHPFile
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
10.54
 readJSONFile
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
7.77
 getCompiledPluralRules
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
3.47
 getPluralRules
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getPluralRuleTypes
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 loadPluralFiles
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 loadPluralFile
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 readSourceFilesAndRegisterDeps
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 readPluralFilesAndRegisterDeps
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 mergeItem
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
8.03
 mergeMagicWords
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getMessagesDirs
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
 loadCoreData
94.12% covered (success)
94.12%
32 / 34
0.00% covered (danger)
0.00%
0 / 1
18.07
 recache
93.65% covered (success)
93.65%
118 / 126
0.00% covered (danger)
0.00%
0 / 1
48.59
 buildPreload
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 unload
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 unloadAll
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 disableBackend
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isLeximorphEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSubitemForTesting
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Language;
8
9use CLDRPluralRuleParser\Error as CLDRPluralRuleError;
10use CLDRPluralRuleParser\Evaluator;
11use DOMDocument;
12use InvalidArgumentException;
13use LogicException;
14use MediaWiki\Config\ConfigException;
15use MediaWiki\Config\ServiceOptions;
16use MediaWiki\HookContainer\HookContainer;
17use MediaWiki\HookContainer\HookRunner;
18use MediaWiki\Json\FormatJson;
19use MediaWiki\Language\Dependency\CacheDependency;
20use MediaWiki\Language\Dependency\ConstantDependency;
21use MediaWiki\Language\Dependency\FileDependency;
22use MediaWiki\Language\Dependency\MainConfigDependency;
23use MediaWiki\MainConfigNames;
24use MediaWiki\MediaWikiServices;
25use Psr\Log\LoggerInterface;
26use RuntimeException;
27use UnexpectedValueException;
28use Wikimedia\Leximorph\Provider as LeximorphProvider;
29use Wikimedia\Leximorph\Provider\PluralRules as LeximorphPluralRulesProvider;
30
31/**
32 * Caching for the contents of localisation files.
33 *
34 * Including for i18n JSON files under `/languages/messages`, `Messages*.php`,
35 * and `*.i18n.php`.
36 *
37 * An instance of this class is available using MediaWikiServices.
38 *
39 * The values retrieved from here are merged, containing items from extension
40 * files, core messages files and the language fallback sequence (e.g. zh-cn ->
41 * zh-hans -> en ). Some common errors are corrected, for example namespace
42 * names with spaces instead of underscores, but heavyweight processing, such
43 * as grammatical transformation, is done by the caller.
44 *
45 * @ingroup Language
46 */
47class LocalisationCache {
48    public const VERSION = 7;
49
50    /**
51     * True if re-caching should only be done on an explicit call to recache().
52     * Setting this reduces the overhead of cache freshness checking, which
53     * requires doing a stat() for every extension i18n file.
54     *
55     * @var bool
56     */
57    private $manualRecache;
58
59    /**
60     * The cache data. 2/3-d array, where the first key is the language code,
61     * the second key is the item key e.g. 'messages', and the optional third key is
62     * an item specific subkey index. Some items are not arrays, and so for those
63     * items, there are no subkeys.
64     *
65     * @var array<string,array>
66     */
67    protected $data = [];
68
69    /**
70     * The source language of cached data items. Only supports messages for now.
71     *
72     * @var array<string,array<string,array<string,string>>>
73     */
74    protected $sourceLanguage = [];
75
76    private HookRunner $hookRunner;
77    /** @var callable[] See comment for parameter in constructor */
78    private $clearStoreCallbacks;
79
80    /**
81     * A 2-d associative array, code/key, where presence indicates that the item
82     * is loaded. Value arbitrary.
83     *
84     * For split items, if set, this indicates that all the subitems have been
85     * loaded.
86     *
87     * @var array<string,array<string,true>>
88     */
89    private $loadedItems = [];
90
91    /**
92     * A 3-d associative array, code/key/subkey, where presence indicates that
93     * the subitem is loaded. Only used for the split items, i.e. ,messages.
94     *
95     * @var array<string,array<string,array<string,true>>>
96     */
97    private $loadedSubitems = [];
98
99    /**
100     * An array where the presence of a key indicates that that language has been
101     * initialised. Initialisation includes checking for cache expiry and doing
102     * any necessary updates.
103     *
104     * @var array<string,true>
105     */
106    private $initialisedLangs = [];
107
108    /**
109     * An array mapping non-existent pseudo-languages to fallback languages. This
110     * is filled by initShallowFallback() when data is requested from a language
111     * that lacks a Messages*.php file.
112     *
113     * @var array<string,string>
114     */
115    private $shallowFallbacks = [];
116
117    /**
118     * @var string[][] language codes to fall back to, in order, when the store
119     *                 allows for cheap multiple queries
120     */
121    private $fallbackCodes = [];
122
123    /**
124     * An array where the keys are codes that have been re-cached by this instance.
125     *
126     * @var array<string,true>
127     */
128    private $recachedLangs = [];
129
130    /**
131     * An array indicating whether core data for a language has been loaded.
132     * If the entry for a language code $code is true,
133     * then {@link self::$data} is guaranteed to contain an array for $code,
134     * with at least an entry (possibly null) for each of the {@link self::CORE_ONLY_KEYS},
135     * and all the core-only keys will be marked as loaded in {@link self::$loadedItems} too.
136     * Additionally, there will be a 'deps' entry for $code with the dependencies tracked so far.
137     *
138     * @var array<string,bool>
139     */
140    private $coreDataLoaded = [];
141
142    /**
143     * All item keys
144     */
145    public const ALL_KEYS = [
146        'fallback', 'namespaceNames', 'bookstoreList',
147        'magicWords', 'messages', 'rtl',
148        'digitTransformTable', 'separatorTransformTable',
149        'minimumGroupingDigits', 'numberingSystem', 'fallback8bitEncoding',
150        'linkPrefixExtension', 'linkTrail', 'linkPrefixCharset',
151        'namespaceAliases', 'dateFormats', 'jsDateFormats', 'datePreferences',
152        'datePreferenceMigrationMap', 'defaultDateFormat',
153        'specialPageAliases', 'imageFiles', 'preloadedMessages',
154        'namespaceGenderAliases', 'digitGroupingPattern', 'pluralRules',
155        'pluralRuleTypes', 'compiledPluralRules', 'formalityIndex'
156    ];
157
158    /**
159     * Keys for items that can only be set in the core message files,
160     * not in extensions. Assignments to these keys in extension messages files
161     * are silently ignored.
162     *
163     * @since 1.41
164     */
165    private const CORE_ONLY_KEYS = [
166        'fallback', 'rtl', 'digitTransformTable', 'separatorTransformTable',
167        'minimumGroupingDigits', 'numberingSystem',
168        'fallback8bitEncoding', 'linkPrefixExtension',
169        'linkTrail', 'linkPrefixCharset', 'datePreferences',
170        'datePreferenceMigrationMap', 'defaultDateFormat', 'digitGroupingPattern',
171        'formalityIndex',
172    ];
173
174    /**
175     * ALL_KEYS - CORE_ONLY_KEYS. All of these can technically be set
176     * both in core and in extension messages files,
177     * though this is not necessarily useful for all these keys.
178     * Some of these keys are mergeable too.
179     *
180     * @since 1.41
181     */
182    private const ALL_EXCEPT_CORE_ONLY_KEYS = [
183        'namespaceNames', 'bookstoreList', 'magicWords', 'messages',
184        'namespaceAliases', 'dateFormats', 'jsDateFormats', 'specialPageAliases',
185        'imageFiles', 'preloadedMessages', 'namespaceGenderAliases',
186        'pluralRules', 'pluralRuleTypes', 'compiledPluralRules',
187    ];
188
189    /** Keys for items which can be localized. */
190    public const ALL_ALIAS_KEYS = [ 'specialPageAliases' ];
191
192    /**
193     * Keys for items which consist of associative arrays, which may be merged
194     * by a fallback sequence.
195     */
196    private const MERGEABLE_MAP_KEYS = [ 'messages', 'namespaceNames',
197        'namespaceAliases', 'dateFormats', 'jsDateFormats', 'imageFiles', 'preloadedMessages'
198    ];
199
200    /**
201     * Keys for items which contain an array of arrays of equivalent aliases
202     * for each subitem. The aliases may be merged by a fallback sequence.
203     */
204    private const MERGEABLE_ALIAS_LIST_KEYS = [ 'specialPageAliases' ];
205
206    /**
207     * Keys for items which contain an associative array, and may be merged if
208     * the primary value contains the special array key "inherit". That array
209     * key is removed after the first merge.
210     */
211    private const OPTIONAL_MERGE_KEYS = [ 'bookstoreList' ];
212
213    /**
214     * Keys for items that are formatted like $magicWords
215     */
216    private const MAGIC_WORD_KEYS = [ 'magicWords' ];
217
218    /**
219     * Keys for items where the subitems are stored in the backend separately.
220     */
221    private const SPLIT_KEYS = [ 'messages' ];
222
223    /**
224     * Keys for items that will be prefixed with its source language code,
225     * which should be stripped out when loading from cache.
226     */
227    private const SOURCE_PREFIX_KEYS = [ 'messages' ];
228
229    /**
230     * Separator for the source language prefix.
231     */
232    private const SOURCEPREFIX_SEPARATOR = ':';
233
234    /**
235     * Keys which are loaded automatically by initLanguage()
236     */
237    private const PRELOADED_KEYS = [ 'dateFormats', 'namespaceNames' ];
238
239    /**
240     * Keys which are per-language metadata, not to be merged.
241     */
242    private const META_KEYS = [ 'deps', 'list', 'preload' ];
243
244    private const PLURAL_FILES = [
245        // Load CLDR plural rules
246        MW_INSTALL_PATH . '/languages/data/plurals.xml',
247        // Override or extend with MW-specific rules
248        MW_INSTALL_PATH . '/languages/data/plurals-mediawiki.xml',
249    ];
250
251    /**
252     * Associative array of cached plural rules. The key is the language code,
253     * the value is an array of plural rules for that language.
254     *
255     * @var array<string,array<int,string>>|null
256     */
257    private static $pluralRules = null;
258
259    /**
260     * Associative array of cached plural rule types. The key is the language
261     * code, the value is an array of plural rule types for that language. For
262     * example, $pluralRuleTypes['ar'] = ['zero', 'one', 'two', 'few', 'many'].
263     * The index for each rule type matches the index for the rule in
264     * $pluralRules, thus allowing correlation between the two. The reason we
265     * don't just use the type names as the keys in $pluralRules is because
266     * Language::convertPlural applies the rules based on numeric order (or
267     * explicit numeric parameter), not based on the name of the rule type. For
268     * example, {{plural:count|wordform1|wordform2|wordform3}}, rather than
269     * {{plural:count|one=wordform1|two=wordform2|many=wordform3}}.
270     *
271     * @var array<string,array<int,string>>|null
272     */
273    private static $pluralRuleTypes = null;
274
275    /**
276     * An array mapping language code to the reason isExpire( $code ) returned true
277     *
278     * @var array<string,string>
279     */
280    private $expiredReason = [];
281
282    /**
283     * Return a suitable LCStore as specified by the given configuration.
284     *
285     * @since 1.34
286     * @param array $conf In the format of $wgLocalisationCacheConf
287     * @param string|false|null $fallbackCacheDir In case 'storeDirectory' isn't specified
288     * @return LCStore
289     */
290    public static function getStoreFromConf( array $conf, $fallbackCacheDir ): LCStore {
291        $storeArg = [
292            'directory' => $conf['storeDirectory'] ?: $fallbackCacheDir,
293        ];
294
295        // Custom classes are supported. For core builtins, short names are stable (since 1.23).
296        $storeClass = match ( $conf['storeClass'] ) {
297            'LCStoreCDB' => LCStoreCDB::class,
298            'LCStoreDB' => LCStoreDB::class,
299            'LCStoreNull' => LCStoreNull::class,
300            'LCStoreStaticArray' => LCStoreStaticArray::class,
301            default => $conf['storeClass'],
302        };
303        if ( !$storeClass ) {
304            if (
305                $conf['store'] === 'files'
306                || $conf['store'] === 'file'
307                || ( $conf['store'] === 'detect' && $storeArg['directory'] )
308            ) {
309                $storeClass = LCStoreCDB::class;
310            } elseif ( $conf['store'] === 'db' || $conf['store'] === 'detect' ) {
311                $storeClass = LCStoreDB::class;
312                $storeArg['server'] = $conf['storeServer'] ?? [];
313            } elseif ( $conf['store'] === 'array' ) {
314                $storeClass = LCStoreStaticArray::class;
315            } else {
316                throw new ConfigException(
317                    'Please set $wgLocalisationCacheConf[\'store\'] to something sensible.'
318                );
319            }
320        }
321
322        return new $storeClass( $storeArg );
323    }
324
325    /**
326     * @internal For use by ServiceWiring
327     */
328    public const CONSTRUCTOR_OPTIONS = [
329        // True to treat all files as expired until they are regenerated by this object.
330        'forceRecache',
331        'manualRecache',
332        MainConfigNames::ExtensionMessagesFiles,
333        MainConfigNames::MessagesDirs,
334        MainConfigNames::TranslationAliasesDirs,
335        MainConfigNames::UseLeximorph,
336    ];
337
338    /**
339     * For constructor parameters, @ref \MediaWiki\MainConfigSchema::LocalisationCacheConf.
340     *
341     * @internal Do not construct directly, use MediaWikiServices instead.
342     * @param ServiceOptions $options
343     * @param LCStore $store What backend to use for storage
344     * @param LoggerInterface $logger
345     * @param callable[] $clearStoreCallbacks To be called whenever the cache is cleared. Can be
346     *   used to clear other caches that depend on this one, such as ResourceLoader's
347     *   MessageBlobStore.
348     * @param LanguageNameUtils $langNameUtils
349     * @param HookContainer $hookContainer
350     */
351    public function __construct(
352        private readonly ServiceOptions $options,
353        private LCStore $store,
354        private readonly LoggerInterface $logger,
355        array $clearStoreCallbacks,
356        private readonly LanguageNameUtils $langNameUtils,
357        HookContainer $hookContainer,
358    ) {
359        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
360
361        $this->clearStoreCallbacks = $clearStoreCallbacks;
362        $this->hookRunner = new HookRunner( $hookContainer );
363
364        // Keep this separate from $this->options so that it can be mutable
365        $this->manualRecache = $options->get( 'manualRecache' );
366    }
367
368    /**
369     * Returns true if the given key is mergeable, that is, if it is an associative
370     * array which can be merged through a fallback sequence.
371     * @param string $key
372     * @return bool
373     */
374    private static function isMergeableKey( string $key ): bool {
375        static $mergeableKeys;
376        $mergeableKeys ??= array_fill_keys( [
377            ...self::MERGEABLE_MAP_KEYS,
378            ...self::MERGEABLE_ALIAS_LIST_KEYS,
379            ...self::OPTIONAL_MERGE_KEYS,
380            ...self::MAGIC_WORD_KEYS,
381        ], true );
382        return isset( $mergeableKeys[$key] );
383    }
384
385    /**
386     * Get a cache item.
387     *
388     * Warning: this may be slow for split items (messages), since it will
389     * need to fetch all the subitems from the cache individually.
390     * @param string $code
391     * @param string $key
392     * @return mixed
393     */
394    public function getItem( $code, $key ) {
395        if ( !isset( $this->loadedItems[$code][$key] ) ) {
396            $this->loadItem( $code, $key );
397        }
398
399        if ( $key === 'fallback' && isset( $this->shallowFallbacks[$code] ) ) {
400            return $this->shallowFallbacks[$code];
401        }
402
403        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
404        return $this->data[$code][$key];
405    }
406
407    /**
408     * Get a subitem, for instance a single message for a given language.
409     * @param string $code
410     * @param string $key
411     * @param string $subkey
412     * @return mixed|null
413     */
414    public function getSubitem( $code, $key, $subkey ) {
415        if ( !isset( $this->loadedSubitems[$code][$key][$subkey] ) &&
416            !isset( $this->loadedItems[$code][$key] )
417        ) {
418            $this->loadSubitem( $code, $key, $subkey );
419        }
420
421        return $this->data[$code][$key][$subkey] ?? null;
422    }
423
424    /**
425     * Get a subitem with its source language. Only supports messages for now.
426     *
427     * @since 1.41
428     * @param string $code
429     * @param string $key
430     * @param string $subkey
431     * @return string[]|null Return [ subitem, sourceLanguage ] if the subitem is defined.
432     */
433    public function getSubitemWithSource( $code, $key, $subkey ) {
434        $subitem = $this->getSubitem( $code, $key, $subkey );
435        // Undefined in the backend.
436        if ( $subitem === null ) {
437            return null;
438        }
439
440        // The source language should have been set, but to avoid a Phan error and to be double sure.
441        return [ $subitem, $this->sourceLanguage[$code][$key][$subkey] ?? $code ];
442    }
443
444    /**
445     * Get the list of subitem keys for a given item.
446     *
447     * This is faster than array_keys($lc->getItem(...)) for the items listed in
448     * self::SPLIT_KEYS.
449     *
450     * Will return null if the item is not found, or false if the item is not an
451     * array.
452     *
453     * @param string $code
454     * @param string $key
455     * @return bool|null|string|string[]
456     */
457    public function getSubitemList( $code, $key ) {
458        if ( in_array( $key, self::SPLIT_KEYS ) ) {
459            return $this->getSubitem( $code, 'list', $key );
460        } else {
461            $item = $this->getItem( $code, $key );
462            if ( is_array( $item ) ) {
463                return array_keys( $item );
464            } else {
465                return false;
466            }
467        }
468    }
469
470    /**
471     * Load an item into the cache.
472     *
473     * @param string $code
474     * @param string $key
475     */
476    private function loadItem( $code, $key ) {
477        if ( isset( $this->loadedItems[$code][$key] ) ) {
478            return;
479        }
480
481        if (
482            in_array( $key, self::CORE_ONLY_KEYS, true ) ||
483            // "synthetic" keys added by loadCoreData based on "fallback"
484            $key === 'fallbackSequence' ||
485            $key === 'originalFallbackSequence'
486        ) {
487            if ( $this->langNameUtils->isValidBuiltInCode( $code ) ) {
488                $this->loadCoreData( $code );
489                return;
490            }
491        }
492
493        if ( !isset( $this->initialisedLangs[$code] ) ) {
494            $this->initLanguage( $code );
495
496            // Check to see if initLanguage() loaded it for us
497            if ( isset( $this->loadedItems[$code][$key] ) ) {
498                return;
499            }
500        }
501
502        if ( isset( $this->shallowFallbacks[$code] ) ) {
503            $this->loadItem( $this->shallowFallbacks[$code], $key );
504
505            return;
506        }
507
508        if ( in_array( $key, self::SPLIT_KEYS ) ) {
509            $subkeyList = $this->getSubitem( $code, 'list', $key );
510            foreach ( $subkeyList as $subkey ) {
511                if ( isset( $this->data[$code][$key][$subkey] ) ) {
512                    continue;
513                }
514                $this->loadSubitem( $code, $key, $subkey );
515            }
516        } else {
517            $this->data[$code][$key] = $this->getFromStore( $code, $key );
518        }
519
520        $this->loadedItems[$code][$key] = true;
521    }
522
523    /**
524     * Get a key from the store and, if active, merge data
525     * from fallback languages.
526     *
527     * @return mixed
528     */
529    private function getFromStore( string $code, string $key ) {
530        if ( $this->store->lateFallback() ) {
531            $result = null;
532            foreach ( $this->getFallbackCodes( $code ) as $langCode ) {
533                $value = $this->store->get( $langCode, $key );
534                $this->mergeItem( $key, $result, $value );
535                if ( in_array( $key, self::META_KEYS ) ) {
536                    break;
537                }
538                if ( is_string( $result ) ) {
539                    // No need to do merges or look further
540                    break;
541                }
542            }
543            return $result;
544        }
545        return $this->store->get( $code, $key );
546    }
547
548    /**
549     * Get the set of language codes, including the current language code and any fallbacks
550     * to read from in order. If fallbacks are disabled this is just the current code.
551     * @param string $code
552     * @return string[]
553     */
554    protected function getFallbackCodes( string $code ): array {
555        if ( !array_key_exists( $code, $this->fallbackCodes ) ) {
556            $this->fallbackCodes[$code] = [
557                $code,
558                ...MediaWikiServices::getInstance()->getLanguageFallback()->getAll( $code )
559            ];
560        }
561        return $this->fallbackCodes[$code];
562    }
563
564    /**
565     * Load a subitem into the cache.
566     *
567     * @param string $code
568     * @param string $key
569     * @param string $subkey
570     */
571    private function loadSubitem( $code, $key, $subkey ) {
572        if ( !in_array( $key, self::SPLIT_KEYS ) ) {
573            $this->loadItem( $code, $key );
574
575            return;
576        }
577
578        if ( !isset( $this->initialisedLangs[$code] ) ) {
579            $this->initLanguage( $code );
580        }
581
582        // Check to see if initLanguage() loaded it for us
583        if ( isset( $this->loadedItems[$code][$key] ) ||
584            isset( $this->loadedSubitems[$code][$key][$subkey] )
585        ) {
586            return;
587        }
588
589        if ( isset( $this->shallowFallbacks[$code] ) ) {
590            $this->loadSubitem( $this->shallowFallbacks[$code], $key, $subkey );
591
592            return;
593        }
594
595        $value = $this->getFromStore( $code, "$key:$subkey" );
596        if ( $value !== null && in_array( $key, self::SOURCE_PREFIX_KEYS ) ) {
597            [
598                $this->sourceLanguage[$code][$key][$subkey],
599                $this->data[$code][$key][$subkey]
600            ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 );
601        } else {
602            $this->data[$code][$key][$subkey] = $value;
603        }
604
605        $this->loadedSubitems[$code][$key][$subkey] = true;
606    }
607
608    /**
609     * Returns true if the cache identified by $code is missing or expired.
610     *
611     * @param string $code
612     *
613     * @return bool
614     */
615    public function isExpired( $code ) {
616        if ( $this->options->get( 'forceRecache' ) && !isset( $this->recachedLangs[$code] ) ) {
617            $this->logger->debug( __METHOD__ . "($code): forced reload" );
618
619            $this->expiredReason[$code] = "Forced rebuild requested";
620            return true;
621        }
622
623        $deps = $this->getFromStore( $code, 'deps' );
624        $keys = $this->getFromStore( $code, 'list' );
625        $preload = $this->getFromStore( $code, 'preload' );
626        // Different keys may expire separately for some stores
627        if ( $deps === null || $keys === null || $preload === null ) {
628            $this->logger->debug( __METHOD__ . "($code): cache missing, need to make one" );
629
630            $this->expiredReason[$code] = "No existing cache";
631            return true;
632        }
633
634        $expiredReason = '';
635
636        $collectExpiredReason = static function ( $reason ) use ( &$expiredReason ) {
637            $expiredReason = $reason;
638        };
639
640        foreach ( $deps as $dep ) {
641            // Because we're unserializing stuff from cache, we
642            // could receive objects of classes that don't exist
643            // anymore (e.g., uninstalled extensions)
644            // When this happens, always expire the cache
645            if ( !$dep instanceof CacheDependency ) {
646                $this->expiredReason[$code] = get_class( $dep ) . " is not a subtype of CacheDependency";
647                return true;
648            }
649
650            if ( $dep->isExpired( $collectExpiredReason ) ) {
651                $this->logger->debug( __METHOD__ . "($code): cache for $code expired due to " .
652                    get_class( $dep ) );
653                $this->expiredReason[$code] = $expiredReason;
654                return true;
655            }
656        }
657
658        return false;
659    }
660
661    /**
662     * Returns a string describing the reason a call to isExpired ( $code ) returned true.
663     *
664     * @param string $code
665     * @return string|null
666     */
667    public function getExpiredReason( $code ) {
668        return $this->expiredReason[$code] ?? null;
669    }
670
671    /**
672     * Initialise a language in this object. Rebuild the cache if necessary.
673     *
674     * @param string $langCode
675     */
676    private function initLanguage( $langCode ) {
677        foreach ( array_reverse( $this->getFallbackCodes( $langCode ) ) as $code ) {
678            if ( isset( $this->initialisedLangs[$code] ) ) {
679                continue;
680            }
681
682            $this->initialisedLangs[$code] = true;
683
684            # If the code is of the wrong form for a Messages*.php file, do a shallow fallback
685            if ( !$this->langNameUtils->isValidBuiltInCode( $code ) ) {
686                $this->initShallowFallback( $code, 'en' );
687
688                continue;
689            }
690
691            # Re-cache the data if necessary
692            if ( !$this->manualRecache && $this->isExpired( $code ) ) {
693                if ( $this->langNameUtils->isSupportedLanguage( $code ) ) {
694                    $this->recache( $code );
695                } elseif ( $code === 'en' ) {
696                    throw new RuntimeException( 'MessagesEn.php is missing.' );
697                } else {
698                    $this->initShallowFallback( $code, 'en' );
699                }
700
701                continue;
702            }
703
704            # Preload some stuff
705            $preload = $this->getItem( $code, 'preload' );
706            if ( $preload === null ) {
707                if ( $this->manualRecache ) {
708                    // No Messages*.php file. Do shallow fallback to en.
709                    if ( $code === 'en' ) {
710                        throw new RuntimeException( 'No localisation cache found for English. ' .
711                            'Please run maintenance/rebuildLocalisationCache.php.' );
712                    }
713                    $this->initShallowFallback( $code, 'en' );
714
715                    break;
716                } else {
717                    throw new RuntimeException( 'Invalid or missing localisation cache.' );
718                }
719            }
720
721            foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
722                if ( !isset( $preload[$key] ) ) {
723                    continue;
724                }
725                foreach ( $preload[$key] as $subkey => $value ) {
726                    if ( $value !== null ) {
727                        [
728                            $this->sourceLanguage[$code][$key][$subkey],
729                            $preload[$key][$subkey]
730                        ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 );
731                    } else {
732                        $preload[$key][$subkey] = null;
733                    }
734                }
735            }
736
737            if ( isset( $this->data[$code] ) ) {
738                foreach ( $preload as $key => $value ) {
739                    // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- see isset() above
740                    $this->mergeItem( $key, $this->data[$code][$key], $value );
741                }
742            } else {
743                $this->data[$code] = $preload;
744            }
745            foreach ( $preload as $key => $item ) {
746                if ( in_array( $key, self::SPLIT_KEYS ) ) {
747                    foreach ( $item as $subkey => $subitem ) {
748                        $this->loadedSubitems[$code][$key][$subkey] = true;
749                    }
750                } else {
751                    $this->loadedItems[$code][$key] = true;
752                }
753            }
754        }
755    }
756
757    /**
758     * Create a fallback from one language to another, without creating a
759     * complete persistent cache.
760     *
761     * @param string $primaryCode
762     * @param string $fallbackCode
763     */
764    private function initShallowFallback( $primaryCode, $fallbackCode ) {
765        $this->data[$primaryCode] =& $this->data[$fallbackCode];
766        $this->loadedItems[$primaryCode] =& $this->loadedItems[$fallbackCode];
767        $this->loadedSubitems[$primaryCode] =& $this->loadedSubitems[$fallbackCode];
768        $this->shallowFallbacks[$primaryCode] = $fallbackCode;
769        $this->coreDataLoaded[$primaryCode] =& $this->coreDataLoaded[$fallbackCode];
770    }
771
772    /**
773     * Read a PHP file containing localisation data.
774     *
775     * @param string $_fileName
776     * @param string $_fileType
777     * @return array
778     */
779    protected function readPHPFile( $_fileName, $_fileType ) {
780        include $_fileName;
781
782        $data = [];
783        if ( $_fileType == 'core' ) {
784            foreach ( self::ALL_KEYS as $key ) {
785                // Not all keys are set in language files, so
786                // check they exist first
787                // @phan-suppress-next-line MediaWikiNoIssetIfDefined  May be set in the included file
788                if ( isset( $$key ) ) {
789                    $data[$key] = $$key;
790                }
791            }
792        } elseif ( $_fileType == 'extension' ) {
793            foreach ( self::ALL_EXCEPT_CORE_ONLY_KEYS as $key ) {
794                // @phan-suppress-next-line MediaWikiNoIssetIfDefined  May be set in the included file
795                if ( isset( $$key ) ) {
796                    $data[$key] = $$key;
797                }
798            }
799        } elseif ( $_fileType == 'aliases' ) {
800            // @phan-suppress-next-line PhanImpossibleCondition May be set in the included file
801            if ( isset( $aliases ) ) {
802                $data['aliases'] = $aliases;
803            }
804        } else {
805            throw new InvalidArgumentException( __METHOD__ . ": Invalid file type: $_fileType" );
806        }
807
808        return $data;
809    }
810
811    /**
812     * Read a JSON file containing localisation messages.
813     *
814     * @param string $fileName Name of file to read
815     * @return array Array with a 'messages' key, or empty array if the file doesn't exist
816     */
817    private function readJSONFile( $fileName ) {
818        if ( !is_readable( $fileName ) ) {
819            return [];
820        }
821
822        $json = file_get_contents( $fileName );
823        if ( $json === false ) {
824            return [];
825        }
826
827        $data = FormatJson::decode( $json, true );
828        if ( $data === null ) {
829            throw new RuntimeException( __METHOD__ . ": Invalid JSON file: $fileName" );
830        }
831
832        // Remove keys starting with '@'; they are reserved for metadata and non-message data
833        foreach ( $data as $key => $unused ) {
834            if ( $key === '' || $key[0] === '@' ) {
835                unset( $data[$key] );
836            }
837        }
838
839        return $data;
840    }
841
842    /**
843     * Get the compiled plural rules for a given language from the XML files.
844     *
845     * @since 1.20
846     * @param string $code
847     * @return array<int,string>|null
848     */
849    private function getCompiledPluralRules( $code ) {
850        $rules = $this->getPluralRules( $code );
851        if ( $rules === null ) {
852            return null;
853        }
854        try {
855            $compiledRules = Evaluator::compile( $rules );
856        } catch ( CLDRPluralRuleError $e ) {
857            $this->logger->debug( $e->getMessage() );
858
859            return [];
860        }
861
862        return $compiledRules;
863    }
864
865    /**
866     * Get the plural rules for a given language from the XML files.
867     *
868     * Cached.
869     *
870     * @since 1.20
871     * @param string $code
872     * @return array<int,string>|null
873     */
874    private function getPluralRules( $code ) {
875        if ( self::$pluralRules === null ) {
876            self::loadPluralFiles();
877        }
878        return self::$pluralRules[$code] ?? null;
879    }
880
881    /**
882     * Get the plural rule types for a given language from the XML files.
883     *
884     * Cached.
885     *
886     * @since 1.22
887     * @param string $code
888     * @return array<int,string>|null
889     */
890    private function getPluralRuleTypes( $code ) {
891        if ( self::$pluralRuleTypes === null ) {
892            self::loadPluralFiles();
893        }
894        return self::$pluralRuleTypes[$code] ?? null;
895    }
896
897    /**
898     * Load the plural XML files.
899     */
900    private static function loadPluralFiles() {
901        foreach ( self::PLURAL_FILES as $fileName ) {
902            self::loadPluralFile( $fileName );
903        }
904    }
905
906    /**
907     * Load a plural XML file with the given filename, compile the relevant
908     * rules, and save the compiled rules in a process-local cache.
909     *
910     * @param string $fileName
911     */
912    private static function loadPluralFile( $fileName ) {
913        // Use file_get_contents instead of DOMDocument::load (T58439)
914        $xml = file_get_contents( $fileName );
915        if ( !$xml ) {
916            throw new RuntimeException( "Unable to read plurals file $fileName" );
917        }
918        $doc = new DOMDocument;
919        $doc->loadXML( $xml );
920        $rulesets = $doc->getElementsByTagName( "pluralRules" );
921        foreach ( $rulesets as $ruleset ) {
922            $codes = $ruleset->getAttribute( 'locales' );
923            $rules = [];
924            $ruleTypes = [];
925            $ruleElements = $ruleset->getElementsByTagName( "pluralRule" );
926            foreach ( $ruleElements as $elt ) {
927                $ruleType = $elt->getAttribute( 'count' );
928                if ( $ruleType === 'other' ) {
929                    // Don't record "other" rules, which have an empty condition
930                    continue;
931                }
932                $rules[] = $elt->nodeValue;
933                $ruleTypes[] = $ruleType;
934            }
935            foreach ( explode( ' ', $codes ) as $code ) {
936                self::$pluralRules[$code] = $rules;
937                self::$pluralRuleTypes[$code] = $ruleTypes;
938            }
939        }
940    }
941
942    /**
943     * Read the data from the source files for a given language, and register
944     * the relevant dependencies in the $deps array.
945     *
946     * @param string $code
947     * @param array &$deps
948     * @return array
949     */
950    private function readSourceFilesAndRegisterDeps( $code, &$deps ) {
951        // This reads in the PHP i18n file with non-messages l10n data
952        $fileName = $this->langNameUtils->getMessagesFileName( $code );
953        if ( !is_file( $fileName ) ) {
954            $data = [];
955        } else {
956            $deps[] = new FileDependency( $fileName );
957            $data = $this->readPHPFile( $fileName, 'core' );
958        }
959
960        return $data;
961    }
962
963    /**
964     * Read and compile the plural data for a given language,
965     * and register the relevant dependencies in the $deps array.
966     *
967     * @param string $code
968     * @param array &$deps
969     * @return array
970     */
971    private function readPluralFilesAndRegisterDeps( $code, &$deps ) {
972        if ( $this->isLeximorphEnabled() ) {
973            $provider = ( new LeximorphProvider( $code, $this->logger ) )->getPluralProvider();
974            $pluralRules = $provider->getPluralRules();
975            $compiledPluralRules = $provider->getCompiledPluralRules();
976            $pluralRuleTypes = $provider->getPluralRuleTypes();
977            $depFiles = LeximorphPluralRulesProvider::PLURAL_FILES;
978        } else {
979            $pluralRules = $this->getPluralRules( $code );
980            $compiledPluralRules = $this->getCompiledPluralRules( $code );
981            $pluralRuleTypes = $this->getPluralRuleTypes( $code );
982            $depFiles = self::PLURAL_FILES;
983        }
984
985        foreach ( $depFiles as $fileName ) {
986            $deps[] = new FileDependency( $fileName );
987        }
988
989        return [
990            // Load CLDR plural rules for JavaScript
991            'pluralRules' => $pluralRules,
992            // And for PHP
993            'compiledPluralRules' => $compiledPluralRules,
994            // Load plural rule types
995            'pluralRuleTypes' => $pluralRuleTypes,
996        ];
997    }
998
999    /**
1000     * Merge two localisation values, a primary and a fallback, overwriting the
1001     * primary value in place.
1002     *
1003     * @param string $key
1004     * @param mixed &$value
1005     * @param mixed $fallbackValue
1006     */
1007    private function mergeItem( $key, &$value, $fallbackValue ) {
1008        if ( $value !== null ) {
1009            if ( $fallbackValue !== null ) {
1010                if ( in_array( $key, self::MERGEABLE_MAP_KEYS ) ) {
1011                    $value += $fallbackValue;
1012                } elseif ( in_array( $key, self::MERGEABLE_ALIAS_LIST_KEYS ) ) {
1013                    $value = array_merge_recursive( $value, $fallbackValue );
1014                } elseif ( in_array( $key, self::OPTIONAL_MERGE_KEYS ) ) {
1015                    if ( !empty( $value['inherit'] ) ) {
1016                        $value = array_merge( $fallbackValue, $value );
1017                    }
1018
1019                    unset( $value['inherit'] );
1020                } elseif ( in_array( $key, self::MAGIC_WORD_KEYS ) ) {
1021                    $this->mergeMagicWords( $value, $fallbackValue );
1022                }
1023            }
1024        } else {
1025            $value = $fallbackValue;
1026        }
1027    }
1028
1029    private function mergeMagicWords( array &$value, array $fallbackValue ): void {
1030        foreach ( $fallbackValue as $magicName => $fallbackInfo ) {
1031            if ( !isset( $value[$magicName] ) ) {
1032                $value[$magicName] = $fallbackInfo;
1033            } else {
1034                $value[$magicName] = [
1035                    $fallbackInfo[0],
1036                    ...array_unique( [
1037                        // First value is 1 if the magic word is case-sensitive, 0 if not
1038                        ...array_slice( $value[$magicName], 1 ),
1039                        ...array_slice( $fallbackInfo, 1 ),
1040                    ] )
1041                ];
1042            }
1043        }
1044    }
1045
1046    /**
1047     * Gets the combined list of messages dirs from
1048     * core and extensions
1049     *
1050     * @since 1.25
1051     * @return array
1052     */
1053    public function getMessagesDirs() {
1054        global $IP;
1055
1056        return [
1057            'core' => "$IP/languages/i18n",
1058            'botpasswords' => "$IP/languages/i18n/botpasswords",
1059            'codex' => "$IP/languages/i18n/codex",
1060            'datetime' => "$IP/languages/i18n/datetime",
1061            'exif' => "$IP/languages/i18n/exif",
1062            'languageconverter' => "$IP/languages/i18n/languageconverter",
1063            'interwiki' => "$IP/languages/i18n/interwiki",
1064            'preferences' => "$IP/languages/i18n/preferences",
1065            'userrights' => "$IP/languages/i18n/userrights",
1066
1067            'nontranslatable' => "$IP/languages/i18n/nontranslatable",
1068
1069            'api' => "$IP/includes/Api/i18n",
1070            'rest' => "$IP/includes/Rest/i18n",
1071            'oojs-ui' => "$IP/resources/lib/ooui/i18n",
1072            'paramvalidator' => "$IP/includes/libs/ParamValidator/i18n",
1073            'installer' => "$IP/includes/Installer/i18n",
1074        ] + $this->options->get( MainConfigNames::MessagesDirs );
1075    }
1076
1077    /**
1078     * Load the core localisation data for a given language code,
1079     * without extensions, using only the process cache.
1080     * See {@link self::$coreDataLoaded} for what this guarantees.
1081     *
1082     * In addition to the core-only keys,
1083     * {@link self::$data} may contain additional entries for $code,
1084     * but those must not be used outside of {@link self::recache()}
1085     * (and accordingly, they are not marked as loaded yet).
1086     */
1087    private function loadCoreData( string $code ) {
1088        if ( !$code ) {
1089            throw new InvalidArgumentException( "Invalid language code requested" );
1090        }
1091        if ( $this->coreDataLoaded[$code] ?? false ) {
1092            return;
1093        }
1094
1095        $coreData = array_fill_keys( self::CORE_ONLY_KEYS, null );
1096        $deps = [];
1097
1098        # Load the primary localisation from the source file
1099        $data = $this->readSourceFilesAndRegisterDeps( $code, $deps );
1100        $this->logger->debug( __METHOD__ . ": got localisation for $code from source" );
1101
1102        # Merge primary localisation
1103        foreach ( $data as $key => $value ) {
1104            $this->mergeItem( $key, $coreData[ $key ], $value );
1105        }
1106
1107        # Fill in the fallback if it's not there already
1108        // @phan-suppress-next-line PhanRedundantValueComparison
1109        if ( ( $coreData['fallback'] === null || $coreData['fallback'] === false ) && $code === 'en' ) {
1110            $coreData['fallback'] = false;
1111            $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'] = [];
1112        } else {
1113            if ( $coreData['fallback'] !== null ) {
1114                $coreData['fallbackSequence'] = array_map( 'trim', explode( ',', $coreData['fallback'] ) );
1115            } else {
1116                $coreData['fallbackSequence'] = [];
1117            }
1118            $len = count( $coreData['fallbackSequence'] );
1119
1120            # Before we add the 'en' fallback for messages, keep a copy of
1121            # the original fallback sequence
1122            $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'];
1123
1124            # Ensure that the sequence ends at 'en' for messages
1125            if ( !$len || $coreData['fallbackSequence'][$len - 1] !== 'en' ) {
1126                $coreData['fallbackSequence'][] = 'en';
1127            }
1128        }
1129
1130        foreach ( $coreData['fallbackSequence'] as $fbCode ) {
1131            // load core fallback data
1132            $fbData = $this->readSourceFilesAndRegisterDeps( $fbCode, $deps );
1133            foreach ( self::CORE_ONLY_KEYS as $key ) {
1134                // core-only keys are not mergeable, only set if not present in core data yet
1135                if ( isset( $fbData[$key] ) && !isset( $coreData[$key] ) ) {
1136                    $coreData[$key] = $fbData[$key];
1137                }
1138            }
1139        }
1140
1141        $coreData['deps'] = $deps;
1142        foreach ( $coreData as $key => $item ) {
1143            $this->data[$code][$key] ??= null;
1144            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- we just set a default null
1145            $this->mergeItem( $key, $this->data[$code][$key], $item );
1146            if (
1147                in_array( $key, self::CORE_ONLY_KEYS, true ) ||
1148                // "synthetic" keys based on "fallback" (see above)
1149                $key === 'fallbackSequence' ||
1150                $key === 'originalFallbackSequence'
1151            ) {
1152                // only mark core-only keys as loaded;
1153                // we may have loaded additional ones from the source file,
1154                // but they are not fully loaded yet, since recache()
1155                // may have to merge in additional values from fallback languages
1156                $this->loadedItems[$code][$key] = true;
1157            }
1158        }
1159
1160        $this->coreDataLoaded[$code] = true;
1161    }
1162
1163    /**
1164     * Load localisation data for a given language for both core and extensions
1165     * and save it to the persistent cache store and the process cache.
1166     *
1167     * @param string $code
1168     */
1169    public function recache( $code ) {
1170        if ( !$code ) {
1171            throw new InvalidArgumentException( "Invalid language code requested" );
1172        }
1173        $this->recachedLangs[ $code ] = true;
1174
1175        # Initial values
1176        $initialData = array_fill_keys( self::ALL_KEYS, null );
1177        $this->data[$code] = [];
1178        $this->loadedItems[$code] = [];
1179        $this->loadedSubitems[$code] = [];
1180        $this->coreDataLoaded[$code] = false;
1181        $this->loadCoreData( $code );
1182        $coreData = $this->data[$code];
1183        $deps = $coreData['deps'];
1184        $coreData += $this->readPluralFilesAndRegisterDeps( $code, $deps );
1185
1186        if ( $this->store->lateFallback() ) {
1187            // This LCStore can handle multiple queries efficiently and
1188            // requests to merge fallback languages at read time.
1189            $codeSequence = [ $code ];
1190        } else {
1191            // Our LCStore prefers to cache pre-combined data with all
1192            // the fallback paths filled out to reduce query count.
1193            $codeSequence = [ $code, ...$coreData['fallbackSequence'] ];
1194        }
1195        $messageDirs = $this->getMessagesDirs();
1196        $translationAliasesDirs = $this->options->get( MainConfigNames::TranslationAliasesDirs );
1197
1198        # Load non-JSON localisation data for extensions
1199        $extensionData = array_fill_keys( $codeSequence, $initialData );
1200        foreach ( $this->options->get( MainConfigNames::ExtensionMessagesFiles ) as $extension => $fileName ) {
1201            if ( isset( $messageDirs[$extension] ) || isset( $translationAliasesDirs[$extension] ) ) {
1202                # This extension has JSON message data; skip the PHP shim
1203                continue;
1204            }
1205
1206            $data = $this->readPHPFile( $fileName, 'extension' );
1207            $used = false;
1208
1209            foreach ( $data as $key => $item ) {
1210                foreach ( $codeSequence as $csCode ) {
1211                    if ( isset( $item[$csCode] ) ) {
1212                        // Keep the behaviour the same as for json messages.
1213                        // TODO: Consider deprecating using a PHP file for messages.
1214                        if ( in_array( $key, self::SOURCE_PREFIX_KEYS ) ) {
1215                            foreach ( $item[$csCode] as $subkey => $_ ) {
1216                                $this->sourceLanguage[$code][$key][$subkey] ??= $csCode;
1217                            }
1218                        }
1219                        $this->mergeItem( $key, $extensionData[$csCode][$key], $item[$csCode] );
1220                        $used = true;
1221                    }
1222                }
1223            }
1224
1225            if ( $used ) {
1226                $deps[] = new FileDependency( $fileName );
1227            }
1228        }
1229
1230        # Load the localisation data for each fallback, then merge it into the full array
1231        $allData = $initialData;
1232        foreach ( $codeSequence as $csCode ) {
1233            $csData = $initialData;
1234
1235            # Load core messages and the extension localisations.
1236            foreach ( $messageDirs as $dirs ) {
1237                foreach ( (array)$dirs as $dir ) {
1238                    $fileName = "$dir/$csCode.json";
1239                    $messages = $this->readJSONFile( $fileName );
1240
1241                    foreach ( $messages as $subkey => $_ ) {
1242                        $this->sourceLanguage[$code]['messages'][$subkey] ??= $csCode;
1243                    }
1244                    $this->mergeItem( 'messages', $csData['messages'], $messages );
1245
1246                    $deps[] = new FileDependency( $fileName );
1247                }
1248            }
1249
1250            foreach ( $translationAliasesDirs as $dirs ) {
1251                foreach ( (array)$dirs as $dir ) {
1252                    $fileName = "$dir/$csCode.json";
1253                    $data = $this->readJSONFile( $fileName );
1254
1255                    foreach ( $data as $key => $item ) {
1256                        // We allow the key in the JSON to be specified in PascalCase similar to key definitions in
1257                        // extension.json, but eventually they are stored in camelCase
1258                        $normalizedKey = lcfirst( $key );
1259
1260                        if ( $normalizedKey === '@metadata' ) {
1261                            // Don't store @metadata information in extension data.
1262                            continue;
1263                        }
1264
1265                        if ( !in_array( $normalizedKey, self::ALL_ALIAS_KEYS ) ) {
1266                            throw new UnexpectedValueException(
1267                                "Invalid key: \"$key\" for " . MainConfigNames::TranslationAliasesDirs . ". " .
1268                                'Valid keys: ' . implode( ', ', self::ALL_ALIAS_KEYS )
1269                            );
1270                        }
1271
1272                        $this->mergeItem( $normalizedKey, $extensionData[$csCode][$normalizedKey], $item );
1273                    }
1274
1275                    $deps[] = new FileDependency( $fileName );
1276                }
1277            }
1278
1279            # Merge non-JSON extension data
1280            if ( isset( $extensionData[$csCode] ) ) {
1281                foreach ( $extensionData[$csCode] as $key => $item ) {
1282                    $this->mergeItem( $key, $csData[$key], $item );
1283                }
1284            }
1285
1286            if ( $csCode === $code ) {
1287                # Merge core data into extension data
1288                foreach ( $coreData as $key => $item ) {
1289                    $this->mergeItem( $key, $csData[$key], $item );
1290                }
1291            } else {
1292                # Load the secondary localisation from the source file to
1293                # avoid infinite cycles on cyclic fallbacks
1294                $fbData = $this->readSourceFilesAndRegisterDeps( $csCode, $deps );
1295                $fbData += $this->readPluralFilesAndRegisterDeps( $csCode, $deps );
1296                # Only merge the keys that make sense to merge
1297                foreach ( self::ALL_KEYS as $key ) {
1298                    if ( !isset( $fbData[ $key ] ) ) {
1299                        continue;
1300                    }
1301
1302                    if ( !isset( $coreData[ $key ] ) || self::isMergeableKey( $key ) ) {
1303                        $this->mergeItem( $key, $csData[ $key ], $fbData[ $key ] );
1304                    }
1305                }
1306            }
1307
1308            # Allow extensions an opportunity to adjust the data for this fallback
1309            $this->hookRunner->onLocalisationCacheRecacheFallback( $this, $csCode, $csData );
1310
1311            # Merge the data for this fallback into the final array
1312            if ( $csCode === $code ) {
1313                $allData = $csData;
1314            } else {
1315                foreach ( self::ALL_KEYS as $key ) {
1316                    if ( !isset( $csData[$key] ) ) {
1317                        continue;
1318                    }
1319
1320                    // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1321                    if ( $allData[$key] === null || self::isMergeableKey( $key ) ) {
1322                        $this->mergeItem( $key, $allData[$key], $csData[$key] );
1323                    }
1324                }
1325            }
1326        }
1327
1328        if ( !isset( $allData['rtl'] ) ) {
1329            throw new RuntimeException( __METHOD__ . ': Localisation data failed validation check! ' .
1330                'Check that your languages/messages/MessagesEn.php file is intact.' );
1331        }
1332
1333        // Add cache dependencies for any referenced configs
1334        // We use the keys prefixed with 'wg' for historical reasons.
1335        $deps['wgExtensionMessagesFiles'] =
1336            new MainConfigDependency( MainConfigNames::ExtensionMessagesFiles );
1337        $deps['wgMessagesDirs'] =
1338            new MainConfigDependency( MainConfigNames::MessagesDirs );
1339        $deps['wgUseLeximorph'] =
1340            new MainConfigDependency( MainConfigNames::UseLeximorph );
1341        $deps['version'] = new ConstantDependency( self::class . '::VERSION' );
1342
1343        # Add dependencies to the cache entry
1344        $allData['deps'] = $deps;
1345
1346        # Replace spaces with underscores in namespace names
1347        if ( isset( $allData['namespaceNames'] ) ) {
1348            $allData['namespaceNames'] = str_replace( ' ', '_', $allData['namespaceNames'] );
1349        }
1350
1351        # And do the same for special page aliases. $page is an array.
1352        if ( isset( $allData['specialPageAliases'] ) ) {
1353            foreach ( $allData['specialPageAliases'] as &$page ) {
1354                $page = str_replace( ' ', '_', $page );
1355            }
1356        }
1357        # Decouple the reference to prevent accidental damage
1358        unset( $page );
1359
1360        # If there were no plural rules, return an empty array
1361        $allData['pluralRules'] ??= [];
1362        $allData['compiledPluralRules'] ??= [];
1363        # If there were no plural rule types, return an empty array
1364        $allData['pluralRuleTypes'] ??= [];
1365
1366        # Set the list keys
1367        $allData['list'] = [];
1368        foreach ( self::SPLIT_KEYS as $key ) {
1369            $allData['list'][$key] = array_keys( $allData[$key] );
1370        }
1371        # Run hooks
1372        $unused = true; // Used to be $purgeBlobs, removed in 1.34
1373        $this->hookRunner->onLocalisationCacheRecache( $this, $code, $allData, $unused );
1374
1375        if ( $this->store->lateFallback() ) {
1376            // Our in-process cache stores merged data, so let it be reloaded
1377            // from the new cache as backend declares it's cheap to do so.
1378        } else {
1379            // Save to the process cache and register the items loaded
1380            $this->data[$code] = $allData;
1381            $this->loadedItems[$code] = [];
1382            $this->loadedSubitems[$code] = [];
1383            foreach ( $allData as $key => $item ) {
1384                $this->loadedItems[$code][$key] = true;
1385            }
1386        }
1387
1388        # Prefix each item with its source language code before save
1389        foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
1390            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1391            foreach ( $allData[$key] as $subKey => $value ) {
1392                // The source language should have been set, but to avoid Phan error and be double sure.
1393                $allData[$key][$subKey] = ( $this->sourceLanguage[$code][$key][$subKey] ?? $code ) .
1394                    self::SOURCEPREFIX_SEPARATOR . $value;
1395            }
1396        }
1397
1398        # Set the preload key
1399        $allData['preload'] = $this->buildPreload( $allData );
1400
1401        # Save to the persistent cache
1402        $this->store->startWrite( $code );
1403        foreach ( $allData as $key => $value ) {
1404            if ( in_array( $key, self::SPLIT_KEYS ) ) {
1405                foreach ( $value as $subkey => $subvalue ) {
1406                    $this->store->set( "$key:$subkey", $subvalue );
1407                }
1408            } else {
1409                $this->store->set( $key, $value );
1410            }
1411        }
1412        $this->store->finishWrite();
1413
1414        # Clear out the MessageBlobStore
1415        # HACK: If using a null (i.e., disabled) storage backend, we
1416        # can't write to the MessageBlobStore either
1417        if ( !$this->store instanceof LCStoreNull ) {
1418            foreach ( $this->clearStoreCallbacks as $callback ) {
1419                $callback();
1420            }
1421        }
1422    }
1423
1424    /**
1425     * Build the preload item from the given pre-cache data.
1426     *
1427     * The preload item will be loaded automatically, improving performance
1428     * for the commonly requested items it contains.
1429     *
1430     * @param array $data
1431     * @return array
1432     */
1433    private function buildPreload( $data ) {
1434        $preload = [ 'messages' => [] ];
1435        foreach ( self::PRELOADED_KEYS as $key ) {
1436            if ( isset( $data[$key] ) ) {
1437                $preload[$key] = $data[$key];
1438            }
1439        }
1440
1441        foreach ( $data['preloadedMessages'] ?? [] as $subkey ) {
1442            if ( isset( $data['messages'][$subkey] ) ) {
1443                $preload['messages'][$subkey] = $data['messages'][$subkey];
1444            }
1445        }
1446
1447        return $preload;
1448    }
1449
1450    /**
1451     * Unload the data for a given language from the object cache.
1452     *
1453     * Reduces memory usage.
1454     *
1455     * @param string $code
1456     */
1457    public function unload( $code ) {
1458        unset( $this->data[$code] );
1459        unset( $this->loadedItems[$code] );
1460        unset( $this->loadedSubitems[$code] );
1461        unset( $this->initialisedLangs[$code] );
1462        unset( $this->shallowFallbacks[$code] );
1463        unset( $this->sourceLanguage[$code] );
1464        unset( $this->coreDataLoaded[$code] );
1465
1466        foreach ( $this->shallowFallbacks as $shallowCode => $fbCode ) {
1467            if ( $fbCode === $code ) {
1468                $this->unload( $shallowCode );
1469            }
1470        }
1471    }
1472
1473    /**
1474     * Unload all data
1475     */
1476    public function unloadAll() {
1477        foreach ( $this->initialisedLangs as $lang => $unused ) {
1478            $this->unload( $lang );
1479        }
1480    }
1481
1482    /**
1483     * Disable the storage backend
1484     */
1485    public function disableBackend() {
1486        $this->store = new LCStoreNull;
1487        $this->manualRecache = false;
1488    }
1489
1490    /**
1491     * Whether localisation cache data should be loaded from Leximorph providers.
1492     */
1493    private function isLeximorphEnabled(): bool {
1494        return (bool)$this->options->get( MainConfigNames::UseLeximorph );
1495    }
1496
1497    /**
1498     * Override a subitem value. For use in tests.
1499     */
1500    public function setSubitemForTesting(
1501        string $code, string $key, string $subkey, mixed $value
1502    ): void {
1503        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
1504            throw new LogicException( __METHOD__ . '() should be called only from tests!' );
1505        }
1506        if ( !isset( $this->initialisedLangs[$code] ) ) {
1507            $this->initLanguage( $code );
1508        }
1509        $this->data[$code][$key][$subkey] = $value;
1510        $this->loadedSubitems[$code][$key][$subkey] = true;
1511    }
1512}
1513
1514/** @deprecated class alias since 1.46 */
1515class_alias( LocalisationCache::class, 'LocalisationCache' );