2121 *
2222 * Locale-based, built on top of PHP internationalization.
2323 *
24+ * @phpstan-type LoadedStrings array<string, array<string, array<string, string>|string>|string|list<string>>
25+ *
2426 * @see \CodeIgniter\Language\LanguageTest
2527 */
2628class Language
@@ -30,20 +32,19 @@ class Language
3032 * from files for faster retrieval on
3133 * second use.
3234 *
33- * @var array
35+ * @var array<non-empty-string, array<non-empty-string, LoadedStrings>>
3436 */
3537 protected $ language = [];
3638
3739 /**
38- * The current language/ locale to work with.
40+ * The current locale to work with.
3941 *
40- * @var string
42+ * @var non-empty- string
4143 */
4244 protected $ locale ;
4345
4446 /**
45- * Boolean value whether the intl
46- * libraries exist on the system.
47+ * Boolean value whether the `intl` extension exists on the system.
4748 *
4849 * @var bool
4950 */
@@ -53,10 +54,13 @@ class Language
5354 * Stores filenames that have been
5455 * loaded so that we don't load them again.
5556 *
56- * @var array
57+ * @var array<non-empty-string, list<non-empty-string>>
5758 */
5859 protected $ loadedFiles = [];
5960
61+ /**
62+ * @param non-empty-string $locale
63+ */
6064 public function __construct (string $ locale )
6165 {
6266 $ this ->locale = $ locale ;
@@ -69,6 +73,8 @@ public function __construct(string $locale)
6973 /**
7074 * Sets the current locale to use when performing string lookups.
7175 *
76+ * @param non-empty-string|null $locale
77+ *
7278 * @return $this
7379 */
7480 public function setLocale (?string $ locale = null )
@@ -89,80 +95,91 @@ public function getLocale(): string
8995 * Parses the language string for a file, loads the file, if necessary,
9096 * getting the line.
9197 *
98+ * @param array<array-key, float|int|string> $args
99+ *
92100 * @return list<string>|string
93101 */
94102 public function getLine (string $ line , array $ args = [])
95103 {
96- // if no file is given, just parse the line
104+ // 1. Format the line as- is if it does not have a file.
97105 if (! str_contains ($ line , '. ' )) {
98106 return $ this ->formatMessage ($ line , $ args );
99107 }
100108
101- // Parse out the file name and the actual alias.
102- // Will load the language file and strings.
109+ // 2. Get the formatted line using the file and line extracted from $line and the current locale.
103110 [$ file , $ parsedLine ] = $ this ->parseLine ($ line , $ this ->locale );
104111
105112 $ output = $ this ->getTranslationOutput ($ this ->locale , $ file , $ parsedLine );
106113
107- if ($ output === null && strpos ($ this ->locale , '- ' )) {
114+ // 3. If not found, try the locale without region (e.g., 'en-US' -> 'en').
115+ if ($ output === null && str_contains ($ this ->locale , '- ' )) {
108116 [$ locale ] = explode ('- ' , $ this ->locale , 2 );
109117
110118 [$ file , $ parsedLine ] = $ this ->parseLine ($ line , $ locale );
111119
112120 $ output = $ this ->getTranslationOutput ($ locale , $ file , $ parsedLine );
113121 }
114122
115- // if still not found, try English
123+ // 4. If still not found, try English.
116124 if ($ output === null ) {
117125 [$ file , $ parsedLine ] = $ this ->parseLine ($ line , 'en ' );
118126
119127 $ output = $ this ->getTranslationOutput ('en ' , $ file , $ parsedLine );
120128 }
121129
130+ // 5. Fallback to the original line if no translation was found.
122131 $ output ??= $ line ;
123132
124133 return $ this ->formatMessage ($ output , $ args );
125134 }
126135
127136 /**
128- * @return array |string|null
137+ * @return list<string> |string|null
129138 */
130139 protected function getTranslationOutput (string $ locale , string $ file , string $ parsedLine )
131140 {
132141 $ output = $ this ->language [$ locale ][$ file ][$ parsedLine ] ?? null ;
142+
133143 if ($ output !== null ) {
134144 return $ output ;
135145 }
136146
137- foreach (explode ('. ' , $ parsedLine ) as $ row ) {
138- if (! isset ($ current )) {
139- $ current = $ this ->language [$ locale ][$ file ] ?? null ;
140- }
147+ // Fallback: try to traverse dot notation
148+ $ current = $ this ->language [$ locale ][$ file ] ?? null ;
149+
150+ if (is_array ($ current )) {
151+ foreach (explode ('. ' , $ parsedLine ) as $ segment ) {
152+ $ output = $ current [$ segment ] ?? null ;
153+
154+ if ($ output === null ) {
155+ break ;
156+ }
141157
142- $ output = $ current [ $ row ] ?? null ;
143- if ( is_array ( $ output )) {
144- $ current = $ output ;
158+ if ( is_array ( $ output )) {
159+ $ current = $ output ;
160+ }
145161 }
146- }
147162
148- if ($ output !== null ) {
149- return $ output ;
163+ if ($ output !== null && ! is_array ($ output )) {
164+ return $ output ;
165+ }
150166 }
151167
152- $ row = current ( explode ( ' . ' , $ parsedLine ));
153- $ key = substr ( $ parsedLine , strlen ( $ row ) + 1 ) ;
168+ // Final fallback: try two-level access manually
169+ [ $ first , $ rest ] = explode ( ' . ' , $ parsedLine , 2 ) + [ '' , '' ] ;
154170
155- return $ this ->language [$ locale ][$ file ][$ row ][$ key ] ?? null ;
171+ return $ this ->language [$ locale ][$ file ][$ first ][$ rest ] ?? null ;
156172 }
157173
158174 /**
159175 * Parses the language string which should include the
160176 * filename as the first segment (separated by period).
177+ *
178+ * @return array{non-empty-string, non-empty-string}
161179 */
162180 protected function parseLine (string $ line , string $ locale ): array
163181 {
164- $ file = substr ($ line , 0 , strpos ($ line , '. ' ));
165- $ line = substr ($ line , strlen ($ file ) + 1 );
182+ [$ file , $ line ] = explode ('. ' , $ line , 2 );
166183
167184 if (! isset ($ this ->language [$ locale ][$ file ]) || ! array_key_exists ($ line , $ this ->language [$ locale ][$ file ])) {
168185 $ this ->load ($ file , $ locale );
@@ -174,10 +191,10 @@ protected function parseLine(string $line, string $locale): array
174191 /**
175192 * Advanced message formatting.
176193 *
177- * @param array |string $message
178- * @param list< string> $args
194+ * @param list<string> |string $message
195+ * @param array<array-key, float|int| string> $args
179196 *
180- * @return array| string
197+ * @return ($message is list< string> ? list<string> : string)
181198 */
182199 protected function formatMessage ($ message , array $ args = [])
183200 {
@@ -194,32 +211,27 @@ protected function formatMessage($message, array $args = [])
194211 }
195212
196213 $ formatted = MessageFormatter::formatMessage ($ this ->locale , $ message , $ args );
214+
197215 if ($ formatted === false ) {
198216 // Format again to get the error message.
199217 try {
200- $ fmt = new MessageFormatter ($ this ->locale , $ message );
201- $ formatted = $ fmt ->format ($ args );
202- $ fmtError = ' " ' . $ fmt ->getErrorMessage () . ' " ( ' . $ fmt ->getErrorCode () . ' ) ' ;
218+ $ formatter = new MessageFormatter ($ this ->locale , $ message );
219+ $ formatted = $ formatter ->format ($ args );
220+ $ fmtError = sprintf ( ' "%s" (%d) ' , $ formatter ->getErrorMessage (), $ formatter ->getErrorCode ()) ;
203221 } catch (IntlException $ e ) {
204- $ fmtError = ' " ' . $ e ->getMessage () . ' " ( ' . $ e ->getCode () . ' ) ' ;
222+ $ fmtError = sprintf ( ' "%s" (%d) ' , $ e ->getMessage (), $ e ->getCode ()) ;
205223 }
206224
207- $ argsString = implode (
208- ', ' ,
209- array_map (static fn ($ element ): string => '" ' . $ element . '" ' , $ args ),
210- );
211- $ argsUrlEncoded = implode (
212- ', ' ,
213- array_map (static fn ($ element ): string => '" ' . rawurlencode ($ element ) . '" ' , $ args ),
214- );
215-
216- log_message (
217- 'error ' ,
218- 'Language.invalidMessageFormat: $message: " ' . $ message
219- . '", $args: ' . $ argsString
220- . ' (urlencoded: ' . $ argsUrlEncoded . '), '
221- . ' MessageFormatter Error: ' . $ fmtError ,
222- );
225+ $ argsAsString = sprintf ('"%s" ' , implode ('", " ' , $ args ));
226+ $ urlEncodedArgs = sprintf ('"%s" ' , implode ('", " ' , array_map (rawurlencode (...), $ args )));
227+
228+ log_message ('error ' , sprintf (
229+ 'Invalid message format: $message: "%s", $args: %s (urlencoded: %s), MessageFormatter Error: %s ' ,
230+ $ message ,
231+ $ argsAsString ,
232+ $ urlEncodedArgs ,
233+ $ fmtError ,
234+ ));
223235
224236 return $ message . "\n【Warning】Also, invalid string(s) was passed to the Language class. See log file for details. " ;
225237 }
@@ -232,7 +244,7 @@ protected function formatMessage($message, array $args = [])
232244 * will return the file's contents, otherwise will merge with
233245 * the existing language lines.
234246 *
235- * @return list<mixed>| null
247+ * @return ($return is true ? LoadedStrings : null)
236248 */
237249 protected function load (string $ file , string $ locale , bool $ return = false )
238250 {
@@ -270,29 +282,35 @@ protected function load(string $file, string $locale, bool $return = false)
270282 }
271283
272284 /**
273- * A simple method for including files that can be
274- * overridden during testing.
285+ * A simple method for including files that can be overridden during testing.
286+ *
287+ * @return LoadedStrings
275288 */
276289 protected function requireFile (string $ path ): array
277290 {
278291 $ files = service ('locator ' )->search ($ path , 'php ' , false );
279292 $ strings = [];
280293
281294 foreach ($ files as $ file ) {
282- // On some OS's we were seeing failures
283- // on this command returning boolean instead
284- // of array during testing, so we've removed
285- // the require_once for now.
286295 if (is_file ($ file )) {
287- $ strings [] = require $ file ;
296+ // On some OS, we were seeing failures on this command returning boolean instead
297+ // of array during testing, so we've removed the require_once for now.
298+ $ loadedStrings = require $ file ;
299+
300+ if (is_array ($ loadedStrings )) {
301+ /** @var LoadedStrings $loadedStrings */
302+ $ strings [] = $ loadedStrings ;
303+ }
288304 }
289305 }
290306
291- if (isset ($ strings [1 ])) {
292- $ string = array_shift ($ strings );
307+ $ count = count ($ strings );
308+
309+ if ($ count > 1 ) {
310+ $ base = array_shift ($ strings );
293311
294- $ strings = array_replace_recursive ($ string , ...$ strings );
295- } elseif (isset ( $ strings [ 0 ]) ) {
312+ $ strings = array_replace_recursive ($ base , ...$ strings );
313+ } elseif ($ count === 1 ) {
296314 $ strings = $ strings [0 ];
297315 }
298316
0 commit comments