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