Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
97 / 97
100.00% covered (success)
100.00%
22 / 22
CRAP
100.00% covered (success)
100.00%
1 / 1
Translator
100.00% covered (success)
100.00%
97 / 97
100.00% covered (success)
100.00%
22 / 22
56
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 translate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translateContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translateDomain
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translateDomainContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translatePlural
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translateContextPlural
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translateDomainPlural
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translateDomainContextPlural
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translateFrom
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 translateDomainFrom
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 translatePluralFrom
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 translateDomainPluralFrom
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 export
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 exportMany
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 domainExports
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
8
 place
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 entry
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 pluralEntry
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 pluralFrom
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 catalog
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 pluralArgs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Verba;
6
7use InvalidArgumentException;
8
9/**
10 * Resolves messages for one locale across an ordered cascade of domains.
11 *
12 * The first domain whose catalog holds a translation wins; a miss falls back
13 * to the message id itself. Domains map to the directory that holds their
14 * `<domain>.<locale>.php` catalog files.
15 *
16 * A locale may name fallback locales, tried in order whenever its own catalog
17 * lacks a string. Resolution is per id and stays within a domain before the
18 * cascade moves on, so the domain cascade outranks the locale fallback.
19 *
20 * @api
21 */
22final class Translator
23{
24    /** @var list<string> */
25    private array $order;
26
27    /** @var list<string> Locale resolution order: the primary locale, then its fallbacks. */
28    private array $locales;
29
30    /** @var array<string, Catalog> */
31    private array $catalogs = [];
32
33    /**
34     * Locale ids may contain only ASCII letters, digits, hyphens, and underscores.
35     *
36     * @param array<string, string> $domains Domain name to i18n directory, in cascade order.
37     * @param list<string> $fallback Locales tried, in order, when the primary locale lacks a string.
38     */
39    public function __construct(
40        public readonly string $locale,
41        private array $domains,
42        array $fallback = [],
43    ) {
44        $this->order = array_keys($domains);
45        $this->locales = array_values(array_unique([$locale, ...$fallback]));
46
47        foreach ($this->locales as $localeId) {
48            // Locale ids become catalog filename segments; reject path characters.
49            if (preg_match('/^[A-Za-z0-9_-]+$/', $localeId) !== 1) {
50                throw new InvalidArgumentException("Invalid locale id '{$localeId}'");
51            }
52        }
53    }
54
55    /**
56     * @param array<array-key, string|int|float> $args
57     */
58    public function translate(string $id, array $args = []): string
59    {
60        return $this->translateFrom(null, $id, $args);
61    }
62
63    /**
64     * @param array<array-key, string|int|float> $args
65     */
66    public function translateContext(string $context, string $id, array $args = []): string
67    {
68        return $this->translateFrom($context, $id, $args);
69    }
70
71    /**
72     * @param array<array-key, string|int|float> $args
73     */
74    public function translateDomain(string $domain, string $id, array $args = []): string
75    {
76        return $this->translateDomainFrom($domain, null, $id, $args);
77    }
78
79    /**
80     * @param array<array-key, string|int|float> $args
81     */
82    public function translateDomainContext(
83        string $domain,
84        string $context,
85        string $id,
86        array $args = [],
87    ): string {
88        return $this->translateDomainFrom($domain, $context, $id, $args);
89    }
90
91    /**
92     * @param array<array-key, string|int|float> $args
93     */
94    public function translatePlural(string $one, string $many, int $n, array $args = []): string
95    {
96        return $this->translatePluralFrom(null, $one, $many, $n, $args);
97    }
98
99    /**
100     * @param array<array-key, string|int|float> $args
101     */
102    public function translateContextPlural(
103        string $context,
104        string $one,
105        string $many,
106        int $n,
107        array $args = [],
108    ): string {
109        return $this->translatePluralFrom($context, $one, $many, $n, $args);
110    }
111
112    /**
113     * @param array<array-key, string|int|float> $args
114     */
115    public function translateDomainPlural(
116        string $domain,
117        string $one,
118        string $many,
119        int $n,
120        array $args = [],
121    ): string {
122        return $this->translateDomainPluralFrom($domain, null, $one, $many, $n, $args);
123    }
124
125    /**
126     * @param array<array-key, string|int|float> $args
127     */
128    // @mago-expect lint:excessive-parameter-list Contextual domain plurals need all five lookup inputs.
129    public function translateDomainContextPlural(
130        string $domain,
131        string $context,
132        string $one,
133        string $many,
134        int $n,
135        array $args = [],
136    ): string {
137        return $this->translateDomainPluralFrom($domain, $context, $one, $many, $n, $args);
138    }
139
140    /**
141     * @param array<array-key, string|int|float> $args
142     */
143    private function translateFrom(?string $context, string $id, array $args): string
144    {
145        foreach ($this->order as $domain) {
146            $entry = $this->entry($domain, $id, $context);
147
148            if ($entry !== null) {
149                return Interpolate::apply($entry, $args);
150            }
151        }
152
153        return Interpolate::apply($id, $args);
154    }
155
156    /**
157     * @param array<array-key, string|int|float> $args
158     */
159    private function translateDomainFrom(
160        string $domain,
161        ?string $context,
162        string $id,
163        array $args,
164    ): string {
165        $entry = array_key_exists($domain, $this->domains)
166            ? $this->entry($domain, $id, $context)
167            : null;
168
169        return Interpolate::apply($entry ?? $id, $args);
170    }
171
172    /**
173     * @param array<array-key, string|int|float> $args
174     */
175    private function translatePluralFrom(
176        ?string $context,
177        string $one,
178        string $many,
179        int $n,
180        array $args,
181    ): string {
182        foreach ($this->order as $domain) {
183            $form = $this->pluralEntry($domain, $one, $n, $args, $context);
184
185            if ($form !== null) {
186                return $form;
187            }
188        }
189
190        return Interpolate::apply($n === 1 ? $one : $many, self::pluralArgs($args, $n));
191    }
192
193    /**
194     * @param array<array-key, string|int|float> $args
195     */
196    // @mago-expect lint:excessive-parameter-list Shared path for domain plurals with optional context.
197    private function translateDomainPluralFrom(
198        string $domain,
199        ?string $context,
200        string $one,
201        string $many,
202        int $n,
203        array $args,
204    ): string {
205        $form = array_key_exists($domain, $this->domains)
206            ? $this->pluralEntry($domain, $one, $n, $args, $context)
207            : null;
208
209        return $form ?? Interpolate::apply($n === 1 ? $one : $many, self::pluralArgs($args, $n));
210    }
211
212    /**
213     * Canonical, JSON-ready catalog data for one domain at the primary locale:
214     * the plural rule id and translated messages. Fallback locales are not
215     * consulted here â€” {@see self::exportMany()} ships the whole resolution
216     * chain. Empty when the domain is not part of this translator's cascade.
217     *
218     * @return array{
219     *     plural: string,
220     *     messages: array<array-key, string|list<string>>,
221     *     contexts?: array<array-key, array<array-key, string|list<string>>>,
222     * }
223     */
224    public function export(string $domain): array
225    {
226        if (!array_key_exists($domain, $this->domains)) {
227            return ['plural' => $this->locale, 'messages' => []];
228        }
229
230        return $this->catalog($domain, $this->locale)->export();
231    }
232
233    /**
234     * The full payload for handing catalogs to the JavaScript runtime: this
235     * translator's locale plus ordered, locale-specific entries for each named
236     * domain and its fallback chain. Each entry carries its own plural rule, so
237     * the JavaScript runtime resolves the same chain the PHP runtime does.
238     * Entries with no reachable messages are omitted. List only domains meant
239     * for the browser â€” the payload ends up world-readable in the page source.
240     *
241     * @param list<string> $domains
242     * @return array{
243     *     locale: string,
244     *     domains: list<array{
245     *         domain: string,
246     *         plural: string,
247     *         messages: array<array-key, string|list<string>>,
248     *         contexts?: array<array-key, array<array-key, string|list<string>>>,
249     *     }>,
250     * }
251     */
252    public function exportMany(array $domains): array
253    {
254        $exports = [];
255
256        foreach ($domains as $domain) {
257            if (array_key_exists($domain, $this->domains)) {
258                array_push($exports, ...$this->domainExports($domain));
259
260                continue;
261            }
262
263            $exports[] = ['domain' => $domain, 'plural' => $this->locale, 'messages' => []];
264        }
265
266        return ['locale' => $this->locale, 'domains' => $exports];
267    }
268
269    /**
270     * Locale-specific export entries for a domain, dropping messages a lookup
271     * can never reach: an id resolved to a string is final, and behind a plural
272     * list only a string can still win (for singular lookups). Fallback entries
273     * with nothing left to add are dropped entirely.
274     *
275     * @return list<array{
276     *     domain: string,
277     *     plural: string,
278     *     messages: array<array-key, string|list<string>>,
279     *     contexts?: array<array-key, array<array-key, string|list<string>>>,
280     * }>
281     */
282    private function domainExports(string $domain): array
283    {
284        $exports = [];
285        $placed = [];
286        $placedContexts = [];
287
288        foreach ($this->locales as $i => $locale) {
289            $export = $this->catalog($domain, $locale)->export();
290            $placement = self::place($export['messages'], $placed);
291            $messages = $placement['messages'];
292            $placed = $placement['placed'];
293            $contexts = [];
294
295            foreach ($export['contexts'] ?? [] as $context => $contextMessages) {
296                $placement = self::place($contextMessages, $placedContexts[$context] ?? []);
297                $placedContexts[$context] = $placement['placed'];
298
299                if ($placement['messages'] !== []) {
300                    $contexts[$context] = $placement['messages'];
301                }
302            }
303
304            if ($i > 0 && $messages === [] && $contexts === []) {
305                continue;
306            }
307
308            $entry = ['domain' => $domain, 'plural' => $export['plural'], 'messages' => $messages];
309
310            if ($contexts !== []) {
311                $entry['contexts'] = $contexts;
312            }
313
314            $exports[] = $entry;
315        }
316
317        return $exports;
318    }
319
320    /**
321     * @param array<array-key, string|list<string>> $messages
322     * @param array<array-key, bool> $placed True for a string, false for a form list.
323     * @return array{
324     *     messages: array<array-key, string|list<string>>,
325     *     placed: array<array-key, bool>,
326     * }
327     */
328    private static function place(array $messages, array $placed): array
329    {
330        $reachable = [];
331
332        foreach ($messages as $id => $message) {
333            $stringPlaced = $placed[$id] ?? null;
334
335            // A placed string is final; behind a form list only a string adds value.
336            if ($stringPlaced === true || $stringPlaced === false && !is_string($message)) {
337                continue;
338            }
339
340            $reachable[$id] = $message;
341            $placed[$id] = is_string($message);
342        }
343
344        return ['messages' => $reachable, 'placed' => $placed];
345    }
346
347    /**
348     * The first string translation for $id across the locale chain, or null.
349     */
350    private function entry(string $domain, string $id, ?string $context): ?string
351    {
352        foreach ($this->locales as $locale) {
353            $entry = $this->catalog($domain, $locale)->get($id, $context);
354
355            if (is_string($entry)) {
356                return $entry;
357            }
358        }
359
360        return null;
361    }
362
363    /**
364     * The interpolated plural form from the first locale in the chain that
365     * translates $one, or null when none does.
366     *
367     * @param array<array-key, string|int|float> $args
368     */
369    private function pluralEntry(
370        string $domain,
371        string $one,
372        int $n,
373        array $args,
374        ?string $context,
375    ): ?string {
376        foreach ($this->locales as $locale) {
377            $form = $this->pluralFrom($this->catalog($domain, $locale), $one, $n, $args, $context);
378
379            if ($form !== null) {
380                return $form;
381            }
382        }
383
384        return null;
385    }
386
387    /**
388     * An empty form list counts as untranslated, like null, so the fallback
389     * chain continues.
390     *
391     * @param array<array-key, string|int|float> $args
392     */
393    private function pluralFrom(
394        Catalog $catalog,
395        string $one,
396        int $n,
397        array $args,
398        ?string $context,
399    ): ?string {
400        $entry = $catalog->get($one, $context);
401
402        if (is_array($entry) && $entry !== []) {
403            $idx = $catalog->form($n);
404            $form = $entry[$idx] ?? $entry[array_key_last($entry)];
405
406            return Interpolate::apply($form, self::pluralArgs($args, $n));
407        }
408
409        if (is_string($entry)) {
410            return Interpolate::apply($entry, self::pluralArgs($args, $n));
411        }
412
413        return null;
414    }
415
416    private function catalog(string $domain, string $locale): Catalog
417    {
418        return $this->catalogs[$domain . "\0" . $locale] ??= Catalog::load(
419            $this->domains[$domain] . '/' . $domain . '.' . $locale . '.php',
420            $locale,
421        );
422    }
423
424    /**
425     * Positional args are passed through untouched; named (or empty) args gain a
426     * `:count` placeholder bound to $n unless the caller already set `count`.
427     *
428     * @param array<array-key, string|int|float> $args
429     * @return array<array-key, string|int|float>
430     */
431    private static function pluralArgs(array $args, int $n): array
432    {
433        if ($args !== [] && array_is_list($args)) {
434            return $args;
435        }
436
437        /** @var array<array-key, string|int|float> $args */
438        if (!array_key_exists('count', $args)) {
439            $args['count'] = $n;
440        }
441
442        return $args;
443    }
444}