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
21/**
22 * @defgroup Language Language
23 */
24
25namespace MediaWiki\Languages;
26
27use Language;
28use LanguageConverter;
29use LocalisationCache;
30use MediaWiki\Config\ServiceOptions;
31use MediaWiki\HookContainer\HookContainer;
32use MediaWiki\MediaWikiServices;
33use MWException;
34
35/**
36 * Internationalisation code
37 * See https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation for more information.
38 *
39 * @ingroup Language
40 * @since 1.35
41 */
42class LanguageFactory {
43	/** @var ServiceOptions */
44	private $options;
45
46	/** @var LocalisationCache */
47	private $localisationCache;
48
49	/** @var LanguageNameUtils */
50	private $langNameUtils;
51
52	/** @var LanguageFallback */
53	private $langFallback;
54
55	/** @var LanguageConverterFactory */
56	private $langConverterFactory;
57
58	/** @var HookContainer */
59	private $hookContainer;
60
61	/** @var array */
62	private $langObjCache = [];
63
64	/** @var array */
65	private $parentLangCache = [];
66
67	/**
68	 * @internal For use by ServiceWiring
69	 */
70	public const CONSTRUCTOR_OPTIONS = [
71		'DummyLanguageCodes',
72		'LangObjCacheSize',
73	];
74
75	/**
76	 * @param ServiceOptions $options
77	 * @param LocalisationCache $localisationCache
78	 * @param LanguageNameUtils $langNameUtils
79	 * @param LanguageFallback $langFallback
80	 * @param LanguageConverterFactory $langConverterFactory
81	 * @param HookContainer $hookContainer
82	 */
83	public function __construct(
84		ServiceOptions $options,
85		LocalisationCache $localisationCache,
86		LanguageNameUtils $langNameUtils,
87		LanguageFallback $langFallback,
88		LanguageConverterFactory $langConverterFactory,
89		HookContainer $hookContainer
90	) {
91		$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
92
93		$this->options = $options;
94		$this->localisationCache = $localisationCache;
95		$this->langNameUtils = $langNameUtils;
96		$this->langFallback = $langFallback;
97		$this->langConverterFactory = $langConverterFactory;
98		$this->hookContainer = $hookContainer;
99	}
100
101	/**
102	 * Get a cached or new language object for a given language code
103	 * @param string $code
104	 * @throws MWException if the language code contains dangerous characters, e.g. HTML special
105	 *  characters or characters illegal in MediaWiki titles.
106	 * @return Language
107	 */
108	public function getLanguage( $code ) : Language {
109		$code = $this->options->get( 'DummyLanguageCodes' )[$code] ?? $code;
110
111		// This is horrible, horrible code, but is necessary to support Language::$mLangObjCache
112		// per the deprecation policy. Kill with fire in 1.36!
113		if (
114			MediaWikiServices::hasInstance() &&
115			$this === MediaWikiServices::getInstance()->getLanguageFactory()
116		) {
117			$this->langObjCache = Language::$mLangObjCache;
118		}
119
120		// Get the language object to process
121		$langObj = $this->langObjCache[$code] ?? $this->newFromCode( $code );
122
123		// Merge the language object in to get it up front in the cache
124		$this->langObjCache = array_merge( [ $code => $langObj ], $this->langObjCache );
125		// Get rid of the oldest ones in case we have an overflow
126		$this->langObjCache =
127			array_slice( $this->langObjCache, 0, $this->options->get( 'LangObjCacheSize' ), true );
128
129		// As above, remove this in 1.36
130		if (
131			MediaWikiServices::hasInstance() &&
132			$this === MediaWikiServices::getInstance()->getLanguageFactory()
133		) {
134			Language::$mLangObjCache = $this->langObjCache;
135		}
136
137		return $langObj;
138	}
139
140	/**
141	 * Create a language object for a given language code
142	 * @param string $code
143	 * @param bool $fallback Whether we're going through language fallback chain
144	 * @throws MWException if the language code or fallback sequence is invalid
145	 * @return Language
146	 */
147	private function newFromCode( $code, $fallback = false ) : Language {
148		if ( !$this->langNameUtils->isValidCode( $code ) ) {
149			throw new MWException( "Invalid language code \"$code\"" );
150		}
151
152		$constructorArgs = [
153			$code,
154			$this->localisationCache,
155			$this->langNameUtils,
156			$this->langFallback,
157			$this->langConverterFactory,
158			$this->hookContainer
159		];
160
161		if ( !$this->langNameUtils->isValidBuiltInCode( $code ) ) {
162			// It's not possible to customise this code with class files, so
163			// just return a Language object. This is to support uselang= hacks.
164			return new Language( ...$constructorArgs );
165		}
166
167		// Check if there is a language class for the code
168		$class = $this->classFromCode( $code, $fallback );
169		// LanguageCode does not inherit Language
170		if ( class_exists( $class ) && is_a( $class, 'Language', true ) ) {
171			return new $class( ...$constructorArgs );
172		}
173
174		// Keep trying the fallback list until we find an existing class
175		$fallbacks = $this->langFallback->getAll( $code );
176		foreach ( $fallbacks as $fallbackCode ) {
177			$class = $this->classFromCode( $fallbackCode );
178			if ( class_exists( $class ) ) {
179				// TODO allow additional dependencies to be injected for subclasses somehow
180				return new $class( ...$constructorArgs );
181			}
182		}
183
184		throw new MWException( "Invalid fallback sequence for language '$code'" );
185	}
186
187	/**
188	 * @param string $code
189	 * @param bool $fallback Whether we're going through language fallback chain
190	 * @return string Name of the language class
191	 */
192	private function classFromCode( $code, $fallback = true ) {
193		if ( $fallback && $code == 'en' ) {
194			return 'Language';
195		} else {
196			return 'Language' . str_replace( '-', '_', ucfirst( $code ) );
197		}
198	}
199
200	/**
201	 * Get the "parent" language which has a converter to convert a "compatible" language
202	 * (in another variant) to this language (eg. zh for zh-cn, but not en for en-gb).
203	 *
204	 * @param string $code
205	 * @return Language|null
206	 * @since 1.22
207	 */
208	public function getParentLanguage( $code ) {
209		// We deliberately use array_key_exists() instead of isset() because we cache null.
210		if ( !array_key_exists( $code, $this->parentLangCache ) ) {
211			$codeBase = explode( '-', $code )[0];
212			if ( !in_array( $codeBase, LanguageConverter::$languagesWithVariants ) ) {
213				$this->parentLangCache[$code] = null;
214				return null;
215			}
216
217			$lang = $this->getLanguage( $codeBase );
218			$converter = $this->langConverterFactory->getLanguageConverter( $lang );
219			if ( !$converter->hasVariant( $code ) ) {
220				$this->parentLangCache[$code] = null;
221				return null;
222			}
223
224			$this->parentLangCache[$code] = $lang;
225		}
226
227		return $this->parentLangCache[$code];
228	}
229}
230