Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
73 / 73
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
Status
100.00% covered (success)
100.00%
73 / 73
100.00% covered (success)
100.00%
6 / 6
17
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
 run
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 inspect
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
3
 inspectSection
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 combine
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 stale
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Verba\Tool;
6
7/**
8 * Reports translation gaps for a domain without touching any files: per locale,
9 * how many ordinary and contextual source messages are missing, untranslated,
10 * translated, and how many catalog entries are now obsolete.
11 *
12 * @api
13 */
14final class Status
15{
16    public function __construct(
17        private readonly Domain $domain,
18    ) {}
19
20    public function run(): StatusReport
21    {
22        $extraction = new Extractor($this->domain)->extract();
23        $fresh = $extraction['messages'];
24        $freshContexts = $extraction['contexts'];
25
26        $locales = [];
27
28        foreach ($this->domain->locales as $locale) {
29            $locales[$locale] = $this->inspect($locale, $fresh, $freshContexts);
30        }
31
32        return new StatusReport($this->domain->name, $locales, $extraction['warnings']);
33    }
34
35    /**
36     * @param array<array-key, Message> $fresh
37     * @param array<array-key, array<array-key, Message>> $freshContexts
38     * @return array{
39     *     missing: int,
40     *     untranslated: int,
41     *     translated: int,
42     *     obsolete: int,
43     *     total: int,
44     *     locations: list<string>,
45     *     parked: list<string>,
46     *     vanished: list<string>,
47     * }
48     */
49    private function inspect(string $locale, array $fresh, array $freshContexts): array
50    {
51        $catalog = CatalogFile::load($this->domain->file($locale));
52        $summary = self::inspectSection($fresh, $catalog->messages);
53        $stale = self::stale($catalog->messages, $catalog->obsolete, $fresh);
54
55        foreach ($freshContexts as $context => $contextFresh) {
56            $contextSummary = self::inspectSection(
57                $contextFresh,
58                $catalog->contexts[$context] ?? [],
59            );
60            $summary = self::combine($summary, $contextSummary);
61        }
62
63        $contextNames = array_unique([
64            ...array_keys($catalog->contexts),
65            ...array_keys($catalog->obsoleteContexts),
66        ]);
67
68        foreach ($contextNames as $context) {
69            $contextStale = self::stale(
70                $catalog->contexts[$context] ?? [],
71                $catalog->obsoleteContexts[$context] ?? [],
72                $freshContexts[$context] ?? [],
73                $context,
74            );
75            $stale['parked'] = [...$stale['parked'], ...$contextStale['parked']];
76            $stale['vanished'] = [...$stale['vanished'], ...$contextStale['vanished']];
77        }
78
79        return [
80            'missing' => $summary['missing'],
81            'untranslated' => $summary['untranslated'],
82            'translated' => $summary['translated'],
83            'obsolete' => count($stale['parked']) + count($stale['vanished']),
84            'total' => $summary['total'],
85            'locations' => $summary['locations'],
86            'parked' => $stale['parked'],
87            'vanished' => $stale['vanished'],
88        ];
89    }
90
91    /**
92     * @param array<array-key, Message> $fresh
93     * @param array<array-key, string|list<string>|null> $messages
94     * @return array{
95     *     missing: int,
96     *     untranslated: int,
97     *     translated: int,
98     *     total: int,
99     *     locations: list<string>,
100     * }
101     */
102    private static function inspectSection(array $fresh, array $messages): array
103    {
104        $missing = 0;
105        $untranslated = 0;
106        $translated = 0;
107        $locations = [];
108
109        foreach ($fresh as $id => $message) {
110            if (!array_key_exists($id, $messages)) {
111                $missing++;
112                $locations = [...$locations, ...$message->locations];
113            } elseif ($messages[$id] === null || $messages[$id] === []) {
114                $untranslated++;
115                $locations = [...$locations, ...$message->locations];
116            } else {
117                $translated++;
118            }
119        }
120
121        return [
122            'missing' => $missing,
123            'untranslated' => $untranslated,
124            'translated' => $translated,
125            'total' => count($fresh),
126            'locations' => $locations,
127        ];
128    }
129
130    /**
131     * @param array{missing: int, untranslated: int, translated: int, total: int, locations: list<string>} $left
132     * @param array{missing: int, untranslated: int, translated: int, total: int, locations: list<string>} $right
133     * @return array{missing: int, untranslated: int, translated: int, total: int, locations: list<string>}
134     */
135    private static function combine(array $left, array $right): array
136    {
137        return [
138            'missing' => $left['missing'] + $right['missing'],
139            'untranslated' => $left['untranslated'] + $right['untranslated'],
140            'translated' => $left['translated'] + $right['translated'],
141            'total' => $left['total'] + $right['total'],
142            'locations' => [...$left['locations'], ...$right['locations']],
143        ];
144    }
145
146    /**
147     * The catalog entries the source no longer holds: those a sync already
148     * parked in the obsolete section, and those still in the live section
149     * because no sync ran since they vanished. A contextual id reads
150     * `[context] id`.
151     *
152     * @param array<array-key, string|list<string>|null> $messages
153     * @param array<array-key, string|list<string>|null> $parked
154     * @param array<array-key, Message> $fresh
155     * @return array{parked: list<string>, vanished: list<string>}
156     */
157    private static function stale(
158        array $messages,
159        array $parked,
160        array $fresh,
161        int|string|null $context = null,
162    ): array {
163        // A numeric id arrives as an integer key.
164        $label = static fn(int|string $id): string => $context === null ? (string) $id : "[{$context}] {$id}";
165        $vanished = [];
166
167        foreach (array_keys($messages) as $id) {
168            if (array_key_exists($id, $fresh) || array_key_exists($id, $parked)) {
169                continue;
170            }
171
172            $vanished[] = $label($id);
173        }
174
175        return ['parked' => array_map($label, array_keys($parked)), 'vanished' => $vanished];
176    }
177}