Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
Catalog
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
9 / 9
24
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 readMessages
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 readContexts
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 readPluralKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 form
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 export
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 translated
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Verba;
6
7use Closure;
8
9/**
10 * The messages of a single domain and locale, plus its plural rule.
11 *
12 * @api
13 */
14final class Catalog
15{
16    /** @var Closure(int): int */
17    private readonly Closure $plural;
18
19    /**
20     * @param array<string, string|list<string>|null> $messages
21     * @param string $pluralKey A locale or rule id resolved through {@see Plurals}.
22     * @param array<string, array<string, string|list<string>|null>> $contexts
23     */
24    public function __construct(
25        private readonly array $messages,
26        private readonly string $pluralKey,
27        private readonly array $contexts = [],
28    ) {
29        $this->plural = Plurals::rule($pluralKey);
30    }
31
32    /**
33     * Loads `<file>`, falling back to an empty catalog when it is missing or
34     * does not return an array.
35     */
36    public static function load(string $file, string $locale): self
37    {
38        if (!is_file($file)) {
39            return new self([], $locale);
40        }
41
42        /** @var mixed $data */
43        $data = require $file;
44
45        if (!is_array($data)) {
46            return new self([], $locale);
47        }
48
49        return new self(
50            self::readMessages($data['messages'] ?? null),
51            self::readPluralKey($data['plural'] ?? null, $locale),
52            self::readContexts($data['contexts'] ?? null),
53        );
54    }
55
56    /**
57     * @return array<string, string|list<string>|null>
58     */
59    private static function readMessages(mixed $messages): array
60    {
61        if (!is_array($messages)) {
62            return [];
63        }
64
65        /** @var array<string, string|list<string>|null> $messages */
66        return $messages;
67    }
68
69    /**
70     * @return array<string, array<string, string|list<string>|null>>
71     */
72    private static function readContexts(mixed $contexts): array
73    {
74        if (!is_array($contexts)) {
75            return [];
76        }
77
78        $read = [];
79
80        foreach ($contexts as $context => $messages) {
81            if (!is_string($context) || !is_array($messages)) {
82                continue;
83            }
84
85            $read[$context] = self::readMessages($messages);
86        }
87
88        return $read;
89    }
90
91    /**
92     * A catalog may borrow another language's rule via a `plural` key holding a
93     * locale/rule id (e.g. `'plural' => 'ru'`); otherwise its own locale rules.
94     */
95    private static function readPluralKey(mixed $plural, string $locale): string
96    {
97        return is_string($plural) ? $plural : $locale;
98    }
99
100    /**
101     * The raw entry for an id: a string, a list of plural forms, or null when
102     * absent or explicitly untranslated.
103     *
104     * @return string|list<string>|null
105     */
106    public function get(string $id, ?string $context = null): string|array|null
107    {
108        $messages = $context === null ? $this->messages : $this->contexts[$context] ?? [];
109
110        return $messages[$id] ?? null;
111    }
112
113    /**
114     * Zero-based plural form index for a count.
115     */
116    public function form(int $n): int
117    {
118        return ($this->plural)($n);
119    }
120
121    /**
122     * The catalog as canonical, JSON-ready data: the plural rule id plus every
123     * translated message. Untranslated ids (null or an empty form list) are
124     * dropped — a consumer of the payload falls back to the id, mirroring the
125     * runtime.
126     *
127     * @return array{
128     *     plural: string,
129     *     messages: array<string, string|list<string>>,
130     *     contexts?: array<string, array<string, string|list<string>>>,
131     * }
132     */
133    public function export(): array
134    {
135        $export = [
136            'plural' => $this->pluralKey,
137            'messages' => self::translated($this->messages),
138        ];
139        $contexts = [];
140
141        foreach ($this->contexts as $context => $messages) {
142            $messages = self::translated($messages);
143
144            if ($messages !== []) {
145                $contexts[$context] = $messages;
146            }
147        }
148
149        if ($contexts !== []) {
150            $export['contexts'] = $contexts;
151        }
152
153        return $export;
154    }
155
156    /**
157     * @param array<string, string|list<string>|null> $messages
158     * @return array<string, string|list<string>>
159     */
160    private static function translated(array $messages): array
161    {
162        $translated = [];
163
164        foreach ($messages as $id => $message) {
165            if ($message === null || $message === []) {
166                continue;
167            }
168
169            $translated[$id] = $message;
170        }
171
172        return $translated;
173    }
174}