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