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