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