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