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<string, string|list<string>>,
221     *     contexts?: array<string, array<string, 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<string, string|list<string>>,
248     *         contexts?: array<string, array<string, 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<string, string|list<string>>,
279     *     contexts?: array<string, array<string, string|list<string>>>,
280     * }>
281     */
282    private function domainExports(string $domain): array
283    {
284        $exports = [];
285
286        /** @var array<string, bool> $placed */
287        $placed = [];
288
289        /** @var array<string, array<string, bool>> $placedContexts */
290        $placedContexts = [];
291
292        foreach ($this->locales as $i => $locale) {
293            $export = $this->catalog($domain, $locale)->export();
294            $placement = self::place($export['messages'], $placed);
295            $messages = $placement['messages'];
296            $placed = $placement['placed'];
297            $contexts = [];
298
299            foreach ($export['contexts'] ?? [] as $context => $contextMessages) {
300                $placement = self::place($contextMessages, $placedContexts[$context] ?? []);
301                $placedContexts[$context] = $placement['placed'];
302
303                if ($placement['messages'] !== []) {
304                    $contexts[$context] = $placement['messages'];
305                }
306            }
307
308            if ($i > 0 && $messages === [] && $contexts === []) {
309                continue;
310            }
311
312            $entry = ['domain' => $domain, 'plural' => $export['plural'], 'messages' => $messages];
313
314            if ($contexts !== []) {
315                $entry['contexts'] = $contexts;
316            }
317
318            $exports[] = $entry;
319        }
320
321        return $exports;
322    }
323
324    /**
325     * @param array<string, string|list<string>> $messages
326     * @param array<string, bool> $placed True for a string, false for a form list.
327     * @return array{
328     *     messages: array<string, string|list<string>>,
329     *     placed: array<string, bool>,
330     * }
331     */
332    private static function place(array $messages, array $placed): array
333    {
334        $reachable = [];
335
336        foreach ($messages as $id => $message) {
337            $stringPlaced = $placed[$id] ?? null;
338
339            // A placed string is final; behind a form list only a string adds value.
340            if ($stringPlaced === true || $stringPlaced === false && !is_string($message)) {
341                continue;
342            }
343
344            $reachable[$id] = $message;
345            $placed[$id] = is_string($message);
346        }
347
348        return ['messages' => $reachable, 'placed' => $placed];
349    }
350
351    /**
352     * The first string translation for $id across the locale chain, or null.
353     */
354    private function entry(string $domain, string $id, ?string $context): ?string
355    {
356        foreach ($this->locales as $locale) {
357            $entry = $this->catalog($domain, $locale)->get($id, $context);
358
359            if (is_string($entry)) {
360                return $entry;
361            }
362        }
363
364        return null;
365    }
366
367    /**
368     * The interpolated plural form from the first locale in the chain that
369     * translates $one, or null when none does.
370     *
371     * @param array<array-key, string|int|float> $args
372     */
373    private function pluralEntry(
374        string $domain,
375        string $one,
376        int $n,
377        array $args,
378        ?string $context,
379    ): ?string {
380        foreach ($this->locales as $locale) {
381            $form = $this->pluralFrom($this->catalog($domain, $locale), $one, $n, $args, $context);
382
383            if ($form !== null) {
384                return $form;
385            }
386        }
387
388        return null;
389    }
390
391    /**
392     * An empty form list counts as untranslated, like null, so the fallback
393     * chain continues.
394     *
395     * @param array<array-key, string|int|float> $args
396     */
397    private function pluralFrom(
398        Catalog $catalog,
399        string $one,
400        int $n,
401        array $args,
402        ?string $context,
403    ): ?string {
404        $entry = $catalog->get($one, $context);
405
406        if (is_array($entry) && $entry !== []) {
407            $idx = $catalog->form($n);
408            $form = $entry[$idx] ?? $entry[array_key_last($entry)];
409
410            return Interpolate::apply($form, self::pluralArgs($args, $n));
411        }
412
413        if (is_string($entry)) {
414            return Interpolate::apply($entry, self::pluralArgs($args, $n));
415        }
416
417        return null;
418    }
419
420    private function catalog(string $domain, string $locale): Catalog
421    {
422        return $this->catalogs[$domain . "\0" . $locale] ??= Catalog::load(
423            $this->domains[$domain] . '/' . $domain . '.' . $locale . '.php',
424            $locale,
425        );
426    }
427
428    /**
429     * Positional args are passed through untouched; named (or empty) args gain a
430     * `:count` placeholder bound to $n unless the caller already set `count`.
431     *
432     * @param array<array-key, string|int|float> $args
433     * @return array<array-key, string|int|float>
434     */
435    private static function pluralArgs(array $args, int $n): array
436    {
437        if ($args !== [] && array_is_list($args)) {
438            return $args;
439        }
440
441        /** @var array<array-key, string|int|float> $args */
442        if (!array_key_exists('count', $args)) {
443            $args['count'] = $n;
444        }
445
446        return $args;
447    }
448}