Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
CatalogFile
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
11 / 11
34
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
 pluralKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 render
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 rows
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 appendContexts
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 value
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 quote
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 section
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 isEntry
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 contexts
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Verba\Tool;
6
7use UnexpectedValueException;
8
9/**
10 * Reads and renders a `<domain>.<locale>.php` catalog for the sync tooling,
11 * exposing the raw message, context, and obsolete sections plus the optional
12 * plural key. Rendering is deterministic: sections are key-sorted and values
13 * are emitted as plain single-quoted literals.
14 *
15 * @api
16 */
17final class CatalogFile
18{
19    /**
20     * @param array<array-key, string|list<string>|null> $messages
21     * @param array<array-key, string|list<string>|null> $obsolete
22     * @param array<array-key, array<array-key, string|list<string>|null>> $contexts
23     * @param array<array-key, array<array-key, string|list<string>|null>> $obsoleteContexts
24     */
25    public function __construct(
26        public array $messages,
27        public array $obsolete,
28        public ?string $plural,
29        public array $contexts = [],
30        public array $obsoleteContexts = [],
31    ) {}
32
33    public static function load(string $file): self
34    {
35        /** @var mixed $data */
36        $data = is_file($file) ? (require $file) : [];
37
38        if (!is_array($data)) {
39            $data = [];
40        }
41
42        return new self(
43            self::section($data['messages'] ?? null, $file, 'messages'),
44            self::section($data['obsolete'] ?? null, $file, 'obsolete'),
45            self::pluralKey($data['plural'] ?? null),
46            self::contexts($data['contexts'] ?? null, $file, 'contexts'),
47            self::contexts($data['obsolete_contexts'] ?? null, $file, 'obsolete_contexts'),
48        );
49    }
50
51    private static function pluralKey(mixed $plural): ?string
52    {
53        return is_string($plural) ? $plural : null;
54    }
55
56    public function render(): string
57    {
58        $lines = ['<?php', '', 'declare(strict_types=1);', '', 'return ['];
59
60        if ($this->plural !== null) {
61            $lines[] = "\t'plural' => " . $this->quote($this->plural) . ',';
62        }
63
64        $lines[] = "\t'messages' => [";
65
66        foreach ($this->rows($this->messages) as $row) {
67            $lines[] = $row;
68        }
69
70        $lines[] = "\t],";
71        $this->appendContexts($lines, 'contexts', $this->contexts);
72
73        if ($this->obsolete !== []) {
74            $lines[] = "\t'obsolete' => [";
75
76            foreach ($this->rows($this->obsolete) as $row) {
77                $lines[] = $row;
78            }
79
80            $lines[] = "\t],";
81        }
82
83        $this->appendContexts($lines, 'obsolete_contexts', $this->obsoleteContexts);
84        $lines[] = '];';
85        $lines[] = '';
86
87        return implode("\n", $lines);
88    }
89
90    /**
91     * @param array<array-key, string|list<string>|null> $section
92     * @return list<string>
93     */
94    private function rows(array $section, int $depth = 2): array
95    {
96        ksort($section, SORT_STRING);
97        $indent = str_repeat("\t", $depth);
98        $rows = [];
99
100        foreach ($section as $id => $value) {
101            $rows[] = $indent . $this->quote((string) $id) . ' => ' . $this->value($value) . ',';
102        }
103
104        return $rows;
105    }
106
107    /**
108     * @param list<string> $lines
109     * @param array<array-key, array<array-key, string|list<string>|null>> $contexts
110     */
111    private function appendContexts(array &$lines, string $name, array $contexts): void
112    {
113        $contexts = array_filter($contexts, static fn(array $messages): bool => $messages !== []);
114
115        if ($contexts === []) {
116            return;
117        }
118
119        ksort($contexts, SORT_STRING);
120        $lines[] = "\t'{$name}' => [";
121
122        foreach ($contexts as $context => $messages) {
123            $lines[] = "\t\t" . $this->quote((string) $context) . ' => [';
124
125            foreach ($this->rows($messages, 3) as $row) {
126                $lines[] = $row;
127            }
128
129            $lines[] = "\t\t],";
130        }
131
132        $lines[] = "\t],";
133    }
134
135    /**
136     * @param string|list<string>|null $value
137     */
138    private function value(string|array|null $value): string
139    {
140        if ($value === null) {
141            return 'null';
142        }
143
144        if (is_string($value)) {
145            return $this->quote($value);
146        }
147
148        return '[' . implode(', ', array_map($this->quote(...), $value)) . ']';
149    }
150
151    private function quote(string $value): string
152    {
153        return "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'";
154    }
155
156    /**
157     * @return array<array-key, string|list<string>|null>
158     */
159    private static function section(mixed $value, string $file, string $path): array
160    {
161        if (!is_array($value)) {
162            return [];
163        }
164
165        $section = [];
166
167        /** @var mixed $entry */
168        foreach ($value as $id => $entry) {
169            if (!self::isEntry($entry)) {
170                throw new UnexpectedValueException(
171                    "Invalid catalog entry {$path}['{$id}'] in '{$file}': "
172                        . 'expected a string, a list of strings, or null',
173                );
174            }
175
176            $section[$id] = $entry;
177        }
178
179        return $section;
180    }
181
182    /**
183     * @psalm-assert-if-true string|list<string>|null $entry
184     */
185    private static function isEntry(mixed $entry): bool
186    {
187        if ($entry === null || is_string($entry)) {
188            return true;
189        }
190
191        return (
192            is_array($entry)
193                && array_is_list($entry)
194                && array_all($entry, static fn(mixed $form): bool => is_string($form))
195        );
196    }
197
198    /**
199     * @return array<array-key, array<array-key, string|list<string>|null>>
200     */
201    private static function contexts(mixed $value, string $file, string $name): array
202    {
203        if (!is_array($value)) {
204            return [];
205        }
206
207        $contexts = [];
208
209        foreach ($value as $context => $messages) {
210            if (!is_array($messages)) {
211                continue;
212            }
213
214            $contexts[$context] = self::section($messages, $file, "{$name}['{$context}']");
215        }
216
217        return $contexts;
218    }
219}