Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
164 / 164
100.00% covered (success)
100.00%
34 / 34
CRAP
100.00% covered (success)
100.00%
1 / 1
Language
100.00% covered (success)
100.00%
164 / 164
100.00% covered (success)
100.00%
34 / 34
62
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 addFindedLocale
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addLines
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 currency
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 date
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 findFilenames
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getCurrentLocale
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentLocaleDirection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultLocale
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDirectories
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFallbackLevel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFallbackLine
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 getFileLines
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLine
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 findLines
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getLines
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resetLines
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSupportedLocales
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isFindedLocale
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lang
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 ordinal
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 getRenderedLine
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 hasLine
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 formatMessage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setCurrentLocale
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setDefaultLocale
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setDirectories
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 addDirectory
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 reindex
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 setFallbackLevel
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSupportedLocales
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setDebugCollector
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getLocaleDirection
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Language Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Language;
11
12use Framework\Helpers\Isolation;
13use Framework\Language\Debug\LanguageCollector;
14use InvalidArgumentException;
15use JetBrains\PhpStorm\ArrayShape;
16use JetBrains\PhpStorm\Pure;
17
18/**
19 * Class Language.
20 *
21 * @see https://www.sitepoint.com/localization-demystified-understanding-php-intl/
22 * @see https://unicode-org.github.io/icu-docs/#/icu4c/classMessageFormat.html
23 *
24 * @package language
25 */
26class Language
27{
28    /**
29     * The current locale.
30     */
31    protected string $currentLocale;
32    /**
33     * The default locale.
34     */
35    protected string $defaultLocale;
36    /**
37     * List of directories to find for files.
38     *
39     * @var array<int,string>
40     */
41    protected array $directories = [];
42    /**
43     * The locale fallback level.
44     */
45    protected FallbackLevel $fallbackLevel = FallbackLevel::default;
46    /**
47     * List with locales of already scanned directories.
48     *
49     * @var array<int,string>
50     */
51    protected array $findedLocales = [];
52    /**
53     * Language lines.
54     *
55     * List of "locale" => "file" => "line" => "text"
56     *
57     * @var array<string,array<string,array<string,string>>>
58     */
59    protected array $languages = [];
60    /**
61     * Supported locales. Any other will be ignored.
62     *
63     * The default locale is always supported.
64     *
65     * @var array<int,string>
66     */
67    protected array $supportedLocales = [];
68    protected LanguageCollector $debugCollector;
69
70    /**
71     * Language constructor.
72     *
73     * @param string $locale The default (and current) locale code
74     * @param array<int,string> $directories List of directory paths to find for language files
75     */
76    public function __construct(string $locale = 'en', array $directories = [])
77    {
78        $this->setDefaultLocale($locale);
79        $this->setCurrentLocale($locale);
80        if ($directories) {
81            $this->setDirectories($directories);
82        }
83    }
84
85    /**
86     * Adds a locale to the list of already scanned directories.
87     *
88     * @param string $locale
89     *
90     * @return static
91     */
92    protected function addFindedLocale(string $locale) : static
93    {
94        $this->findedLocales[] = $locale;
95        return $this;
96    }
97
98    /**
99     * Adds custom lines for a specific locale.
100     *
101     * Useful to set lines from a database or any parsed file.
102     *
103     * NOTE: This function will always replace the old lines, as given from files.
104     *
105     * @param string $locale The locale code
106     * @param string $file The file name
107     * @param array<string> $lines An array of "line" => "text"
108     *
109     * @return static
110     */
111    public function addLines(string $locale, string $file, array $lines) : static
112    {
113        if ( ! $this->isFindedLocale($locale)) {
114            // Certify that all directories are scanned first
115            // So, this method always have priority on replacements
116            $this->getLine($locale, $file, '');
117        }
118        $this->languages[$locale][$file] = isset($this->languages[$locale][$file])
119            ? \array_replace($this->languages[$locale][$file], $lines)
120            : $lines;
121        return $this;
122    }
123
124    /**
125     * Gets a currency value formatted in a given locale.
126     *
127     * @param float $value The money value
128     * @param string $currency The Currency code. i.e. USD, BRL, JPY
129     * @param string|null $locale A custom locale or null to use the current
130     *
131     * @see https://en.wikipedia.org/wiki/ISO_4217#Active_codes
132     *
133     * @return string
134     */
135    public function currency(float $value, string $currency, string $locale = null) : string
136    {
137        // @phpstan-ignore-next-line
138        return \NumberFormatter::create(
139            $locale ?? $this->getCurrentLocale(),
140            \NumberFormatter::CURRENCY
141        )->formatCurrency($value, $currency);
142    }
143
144    /**
145     * Gets a formatted date in a given locale.
146     *
147     * @param int $time An Unix timestamp
148     * @param string|null $style One of: short, medium, long or full. Leave null to use short
149     * @param string|null $locale A custom locale or null to use the current
150     *
151     * @throws InvalidArgumentException for invalid style format
152     *
153     * @return string
154     */
155    public function date(int $time, string $style = null, string $locale = null) : string
156    {
157        if ($style && ! \in_array($style, ['short', 'medium', 'long', 'full'], true)) {
158            throw new InvalidArgumentException('Invalid date style format: ' . $style);
159        }
160        $style = $style ?: 'short';
161        // @phpstan-ignore-next-line
162        return \MessageFormatter::formatMessage(
163            $locale ?? $this->getCurrentLocale(),
164            "{time, date, {$style}}",
165            ['time' => $time]
166        );
167    }
168
169    /**
170     * Find for absolute file paths from where language lines can be loaded.
171     *
172     * @param string $locale The required locale
173     * @param string $file The required file
174     *
175     * @return array<int,string> a list of valid filenames
176     */
177    #[Pure]
178    protected function findFilenames(string $locale, string $file) : array
179    {
180        $filenames = [];
181        foreach ($this->getDirectories() as $directory) {
182            $path = $directory . $locale . \DIRECTORY_SEPARATOR . $file . '.php';
183            if (\is_file($path)) {
184                $filenames[] = $path;
185            }
186        }
187        return $filenames;
188    }
189
190    /**
191     * Gets the current locale.
192     *
193     * @return string
194     */
195    #[Pure]
196    public function getCurrentLocale() : string
197    {
198        return $this->currentLocale;
199    }
200
201    /**
202     * Gets the current locale directionality.
203     *
204     * @return string 'ltr' for Left-To-Right ot 'rtl' for Right-To-Left
205     */
206    #[Pure]
207    public function getCurrentLocaleDirection() : string
208    {
209        return static::getLocaleDirection($this->getCurrentLocale());
210    }
211
212    /**
213     * Gets the default locale.
214     *
215     * @return string
216     */
217    #[Pure]
218    public function getDefaultLocale() : string
219    {
220        return $this->defaultLocale;
221    }
222
223    /**
224     * Gets the list of directories where language files can be finded.
225     *
226     * @return array<int,string>
227     */
228    #[Pure]
229    public function getDirectories() : array
230    {
231        return $this->directories;
232    }
233
234    /**
235     * Gets the Fallback Level.
236     *
237     * @return FallbackLevel
238     */
239    #[Pure]
240    public function getFallbackLevel() : FallbackLevel
241    {
242        return $this->fallbackLevel;
243    }
244
245    /**
246     * Gets a text line and locale according the Fallback Level.
247     *
248     * @param string $locale The locale to get his fallback line
249     * @param string $file The file
250     * @param string $line The line
251     *
252     * @return array<int,string|null> Two numeric keys containg the used locale and text
253     */
254    #[ArrayShape(['string', 'string|null'])]
255    protected function getFallbackLine(string $locale, string $file, string $line) : array
256    {
257        $text = null;
258        $level = $this->getFallbackLevel()->value;
259        // Fallback to parent
260        if ($level > FallbackLevel::none->value && \strpos($locale, '-') > 1) {
261            [$locale] = \explode('-', $locale, 2);
262            $text = $this->getLine($locale, $file, $line);
263        }
264        // Fallback to default
265        if ($text === null
266            && $level > FallbackLevel::parent->value
267            && $locale !== $this->getDefaultLocale()
268        ) {
269            $locale = $this->getDefaultLocale();
270            $text = $this->getLine($locale, $file, $line);
271        }
272        return [
273            $locale,
274            $text,
275        ];
276    }
277
278    /**
279     * @param string $filename
280     *
281     * @return array<int,string>
282     */
283    protected function getFileLines(string $filename) : array
284    {
285        return Isolation::require($filename);
286    }
287
288    /**
289     * Gets a language line text.
290     *
291     * @param string $locale The required locale
292     * @param string $file The required file
293     * @param string $line The required line
294     *
295     * @return string|null The text of the line or null if the line is not found
296     */
297    protected function getLine(string $locale, string $file, string $line) : ?string
298    {
299        if (isset($this->languages[$locale][$file][$line])) {
300            return $this->languages[$locale][$file][$line];
301        }
302        if ( ! \in_array($locale, $this->getSupportedLocales(), true)) {
303            return null;
304        }
305        $this->addFindedLocale($locale);
306        $this->findLines($locale, $file);
307        return $this->languages[$locale][$file][$line] ?? null;
308    }
309
310    /**
311     * Find and add lines.
312     *
313     * This method can be overridden to find lines in custom storage, such as
314     * in a database table.
315     *
316     * @param string $locale
317     * @param string $file
318     *
319     * @return static
320     */
321    protected function findLines(string $locale, string $file) : static
322    {
323        foreach ($this->findFilenames($locale, $file) as $filename) {
324            $this->addLines($locale, $file, $this->getFileLines($filename));
325        }
326        return $this;
327    }
328
329    /**
330     * Gets the list of available locales, lines and texts.
331     *
332     * @return array<string,array<string,array<string,string>>>
333     */
334    #[Pure]
335    public function getLines() : array
336    {
337        return $this->languages;
338    }
339
340    public function resetLines() : static
341    {
342        $this->languages = [];
343        return $this;
344    }
345
346    /**
347     * Gets the list of Supported Locales.
348     *
349     * @return array<int,string>
350     */
351    #[Pure]
352    public function getSupportedLocales() : array
353    {
354        return $this->supportedLocales;
355    }
356
357    /**
358     * Tells if a locale already was found in the directories.
359     *
360     * @param string $locale The locale
361     *
362     * @see \Framework\Language\Language::getLine()
363     *
364     * @return bool
365     */
366    #[Pure]
367    protected function isFindedLocale(string $locale) : bool
368    {
369        return \in_array($locale, $this->findedLocales, true);
370    }
371
372    /**
373     * Renders a language file line with dot notation format.
374     *
375     * E.g. home.hello matches home for file and hello for line.
376     *
377     * @param string $line The dot notation file line
378     * @param array<mixed> $args The arguments to be used in the formatted text
379     * @param string|null $locale A custom locale or null to use the current
380     *
381     * @return string|null The rendered text or null if not found
382     */
383    public function lang(string $line, array $args = [], string $locale = null) : ?string
384    {
385        [$file, $line] = \explode('.', $line, 2);
386        return $this->render($file, $line, $args, $locale);
387    }
388
389    /**
390     * Gets an ordinal number in a given locale.
391     *
392     * @param int $number The number to be converted to ordinal
393     * @param string|null $locale A custom locale or null to use the current
394     *
395     * @return string
396     */
397    public function ordinal(int $number, string $locale = null) : string
398    {
399        // @phpstan-ignore-next-line
400        return \MessageFormatter::formatMessage(
401            $locale ?? $this->getCurrentLocale(),
402            '{number, ordinal}',
403            ['number' => $number]
404        );
405    }
406
407    /**
408     * Renders a language file line.
409     *
410     * @param string $file The file
411     * @param string $line The file line
412     * @param array<mixed> $args The arguments to be used in the formatted text
413     * @param string|null $locale A custom locale or null to use the current
414     *
415     * @return string The rendered text or file.line expression
416     */
417    public function render(
418        string $file,
419        string $line,
420        array $args = [],
421        string $locale = null
422    ) : string {
423        if (isset($this->debugCollector)) {
424            $start = \microtime(true);
425            $rendered = $this->getRenderedLine($file, $line, $args, $locale);
426            $end = \microtime(true);
427            $this->debugCollector->adddata([
428                'start' => $start,
429                'end' => $end,
430                'file' => $file,
431                'line' => $line,
432                'locale' => $rendered['locale'],
433                'message' => $rendered['message'],
434            ]);
435            return $rendered['message'];
436        }
437        return $this->getRenderedLine($file, $line, $args, $locale)['message'];
438    }
439
440    /**
441     * @param string $file
442     * @param string $line
443     * @param array<mixed> $args
444     * @param string|null $locale
445     *
446     * @return array<string,string>
447     */
448    #[ArrayShape(['locale' => 'string', 'message' => 'string'])]
449    protected function getRenderedLine(
450        string $file,
451        string $line,
452        array $args = [],
453        string $locale = null
454    ) : array {
455        $locale ??= $this->getCurrentLocale();
456        $text = $this->getLine($locale, $file, $line);
457        if ($text === null) {
458            [$locale, $text] = $this->getFallbackLine($locale, $file, $line);
459        }
460        if ($text !== null) {
461            $text = $this->formatMessage($text, $args, $locale);
462        }
463        return [
464            'locale' => $locale,
465            'message' => $text ?? ($file . '.' . $line),
466        ];
467    }
468
469    /**
470     * Checks if Language has a line.
471     *
472     * @param string $file The file
473     * @param string $line The file line
474     * @param string|null $locale A custom locale or null to use the current
475     *
476     * @return bool True if the line is found, otherwise false
477     */
478    public function hasLine(string $file, string $line, string $locale = null) : bool
479    {
480        $locale ??= $this->getCurrentLocale();
481        $text = $this->getLine($locale, $file, $line);
482        if ($text === null) {
483            $text = $this->getFallbackLine($locale, $file, $line)[1];
484        }
485        return $text !== null;
486    }
487
488    /**
489     * @param string $text
490     * @param array<mixed> $args
491     * @param string|null $locale
492     *
493     * @return string
494     */
495    public function formatMessage(string $text, array $args = [], string $locale = null) : string
496    {
497        $args = \array_map(static function ($arg) : string {
498            return (string) $arg;
499        }, $args);
500        $locale ??= $this->getCurrentLocale();
501        return \MessageFormatter::formatMessage($locale, $text, $args) ?: $text;
502    }
503
504    /**
505     * Sets the current locale.
506     *
507     * @param string $locale The current locale. This automatically is set as
508     * one of Supported Locales.
509     *
510     * @return static
511     */
512    public function setCurrentLocale(string $locale) : static
513    {
514        $this->currentLocale = $locale;
515        $locales = $this->getSupportedLocales();
516        $locales[] = $locale;
517        $this->setSupportedLocales($locales);
518        return $this;
519    }
520
521    /**
522     * Sets the default locale.
523     *
524     * @param string $locale The default locale. This automatically is set as
525     * one of Supported Locales.
526     *
527     * @return static
528     */
529    public function setDefaultLocale(string $locale) : static
530    {
531        $this->defaultLocale = $locale;
532        $locales = $this->getSupportedLocales();
533        $locales[] = $locale;
534        $this->setSupportedLocales($locales);
535        return $this;
536    }
537
538    /**
539     * Sets a list of directories where language files can be found.
540     *
541     * @param array<string> $directories a list of valid directory paths
542     *
543     * @throws InvalidArgumentException if a directory path is inaccessible
544     *
545     * @return static
546     */
547    public function setDirectories(array $directories) : static
548    {
549        $dirs = [];
550        foreach ($directories as $directory) {
551            $path = \realpath($directory);
552            if ( ! $path || ! \is_dir($path)) {
553                throw new InvalidArgumentException('Directory path inaccessible: ' . $directory);
554            }
555            $dirs[] = $path . \DIRECTORY_SEPARATOR;
556        }
557        $this->directories = $dirs ? \array_unique($dirs) : [];
558        $this->reindex();
559        return $this;
560    }
561
562    /**
563     * @param string $directory
564     *
565     * @return static
566     */
567    public function addDirectory(string $directory) : static
568    {
569        $this->setDirectories(\array_merge([
570            $directory,
571        ], $this->getDirectories()));
572        return $this;
573    }
574
575    protected function reindex() : void
576    {
577        $this->findedLocales = [];
578        foreach ($this->languages as $locale => $files) {
579            foreach (\array_keys($files) as $file) {
580                $this->findLines($locale, $file);
581            }
582            $this->addFindedLocale($locale);
583        }
584    }
585
586    /**
587     * Sets the Fallback Level.
588     *
589     * @param FallbackLevel $level
590     *
591     * @return static
592     */
593    public function setFallbackLevel(FallbackLevel $level) : static
594    {
595        $this->fallbackLevel = $level;
596        return $this;
597    }
598
599    /**
600     * Sets a list of Supported Locales.
601     *
602     * NOTE: the default locale always is supported. But the current can be exclude
603     * if this function is called after {@see Language::setCurrentLocale()}.
604     *
605     * @param array<string> $locales the supported locales
606     *
607     * @return static
608     */
609    public function setSupportedLocales(array $locales) : static
610    {
611        $locales[] = $this->getDefaultLocale();
612        $locales = \array_unique($locales);
613        \sort($locales);
614        $this->supportedLocales = $locales;
615        $this->reindex();
616        return $this;
617    }
618
619    public function setDebugCollector(LanguageCollector $debugCollector) : static
620    {
621        $this->debugCollector = $debugCollector;
622        $this->debugCollector->setLanguage($this);
623        return $this;
624    }
625
626    /**
627     * Gets text directionality based on locale.
628     *
629     * @param string $locale The locale code
630     *
631     * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/dir
632     * @see https://meta.wikimedia.org/wiki/Template:List_of_language_names_ordered_by_code
633     *
634     * @return string 'ltr' for Left-To-Right ot 'rtl' for Right-To-Left
635     */
636    #[Pure]
637    public static function getLocaleDirection(string $locale) : string
638    {
639        $locale = \strtolower($locale);
640        $locale = \strtr($locale, ['_' => '-']);
641        if (\in_array($locale, [
642            'ar',
643            'arc',
644            'ckb',
645            'dv',
646            'fa',
647            'ha',
648            'he',
649            'khw',
650            'ks',
651            'ps',
652            'ur',
653            'uz-af',
654            'yi',
655        ], true)) {
656            return 'rtl';
657        }
658        return 'ltr';
659    }
660}