-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathExtractor.php
More file actions
319 lines (272 loc) · 10.3 KB
/
Copy pathExtractor.php
File metadata and controls
319 lines (272 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
<?php
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/**
* Italix I18n - Extractor
*
* @package Italix\I18n
*/
declare(strict_types=1);
namespace Italix\I18n;
/**
* Which keys the code asks for, and which the catalogues answer.
*
* Translation files rot in two directions and neither is visible from reading
* them: a key used in a template that nobody ever translated shows the raw key
* to a user, and a key left behind after the screen was deleted is dead weight a
* translator keeps paying for.
*
* ## What this deliberately does not do
*
* It does not evaluate anything. `$t->get($key)` and `$t->get('a.' . $suffix)`
* are **reported as dynamic**, not guessed at. A tool that guessed would produce
* a "missing" list containing keys that do not exist and an "unused" list
* missing keys that do, and both lists would then be ignored — which is worse
* than not having them.
*/
final class Extractor
{
/**
* Variable names that hold a translator.
*
* **The receiver has to be named**, and finding that out cost a run against
* a real codebase: matching a bare `->get('…')` reported `$view->get('main')`,
* `$form->get('email')` and `$context->get('data')` as missing translations.
* A report that is mostly noise is a report nobody reads, which is the same
* outcome as having no report — only more expensive.
*/
public const DEFAULT_RECEIVERS = ['t', 'translator', 'i18n', 'lang'];
/**
* Class names a translator is reached through statically.
*
* `T::e('admin.title')` is invisible to the receiver patterns above, because
* there is no `$t` in it. Left unlisted, a codebase that adopts the facade
* loses this tool without being told: `lang:check` keeps reporting "nothing
* missing" while checking a shrinking fraction of the keys, and a typo
* renders the key itself on the page.
*
* Named for the same reason receivers are named: a bare `::e('…')` would
* match anything.
*/
public const DEFAULT_FACADES = ['T'];
/** @var string[] */
private array $receivers;
/** @var string[] */
private array $facades;
/**
* A key that is computed rather than written.
*
* Two shapes, and the second is the one that bites: `get($key)` is obviously
* dynamic, but `get('admin.' . $suffix)` *starts* with a quoted string and
* a pattern that stops at the closing quote happily extracts `admin.` as a
* key. Requiring the literal to be followed by `,` or `)` is what tells the
* two apart — measured, after a test caught `admin.` in the key list.
*/
/** @var string[] built from the receiver list */
private array $patterns;
/** @var string[] */
private array $dynamic_patterns;
/** @var string[] */
private array $extensions;
/** @var string[] */
private array $skip_dirs;
/**
* @param string[] $extensions
* @param string[] $skip_dirs
*/
/**
* @param string[] $extensions
* @param string[] $skip_dirs
* @param string[] $receivers variable names holding a translator
* @param string[] $facades class names a translator is reached through statically
*/
public function __construct(
array $extensions = ['php'],
array $skip_dirs = ['vendor', 'node_modules', '.git'],
array $receivers = self::DEFAULT_RECEIVERS,
array $facades = self::DEFAULT_FACADES
) {
$this->extensions = array_map('strtolower', $extensions);
$this->skip_dirs = $skip_dirs;
$this->receivers = $receivers;
$this->facades = $facades;
// `$t->get(…)` and `$this->t->get(…)`, and nothing else.
$who = implode('|', array_map(static function (string $one): string {
return preg_quote($one, '~');
}, $receivers));
$call = '(?:\\$(?:' . $who . ')|->\\s*(?:' . $who . '))\\s*->\\s*';
// `T::e(…)`, `\Italix\I18n\T::choice_e(…)`. The encoding variants are
// here too: they are the same lookup with the result typed, and a key
// only reachable through e() is still a key.
$facade = $facades === []
? null
: '(?:\\\\?(?:[A-Za-z_][A-Za-z0-9_]*\\\\)*)?(?:' . implode('|', array_map(
static function (string $one): string {
return preg_quote($one, '~');
},
$facades
)) . ')\\s*::\\s*';
$methods_c = '(?:get|choice_e|choice|has|e)';
$this->patterns = [
'~' . $call . '(?:get|choice|has)\\s*\\(\\s*([\'"])((?:(?!\\1).)*)\\1\\s*[,)]~',
];
$this->dynamic_patterns = [
'~' . $call . '(?:get|choice)\\s*\\(\\s*(?:[^\'")\\s]|([\'"])(?:(?!\\1).)*\\1\\s*\\.)~',
];
if ($facade !== null) {
$this->patterns[] =
'~' . $facade . $methods_c . '\\s*\\(\\s*([\'"])((?:(?!\\1).)*)\\1\\s*[,)]~';
$this->dynamic_patterns[] =
'~' . $facade . $methods_c . '\\s*\\(\\s*(?:[^\'")\\s]|([\'"])(?:(?!\\1).)*\\1\\s*\\.)~';
}
}
/** Whether a line computes its key rather than writing it. */
private function is_dynamic(string $line): bool
{
foreach ($this->dynamic_patterns as $pattern) {
if (preg_match($pattern, $line) === 1) {
return true;
}
}
return false;
}
/**
* @return string[]
*/
public function receivers(): array
{
return $this->receivers;
}
/**
* Every literal key used under these roots.
*
* @param string[] $roots
*
* @return array{keys: array<string, string[]>, dynamic: string[], files_n: int}
* keys: key => where it was seen; dynamic: places a key was computed
*/
public function scan(array $roots): array
{
$keys = [];
$dynamic = [];
$files_n = 0;
foreach ($roots as $root) {
foreach ($this->files_under($root) as $file) {
$files_n++;
$source = (string) file_get_contents($file);
$lines = explode("\n", $source);
foreach ($lines as $i => $line) {
$where = $file . ':' . ($i + 1);
foreach ($this->patterns as $pattern) {
if (preg_match_all($pattern, $line, $matches, PREG_SET_ORDER) === false) {
continue;
}
foreach ($matches as $match) {
$key_c = $match[2];
// A key is a dot path: no spaces, no empty
// segments, no trailing dot. The trailing-dot rule
// is what stops half a concatenation being taken
// for a key.
if (preg_match('~^[A-Za-z0-9_][A-Za-z0-9_-]*(\.[A-Za-z0-9_][A-Za-z0-9_-]*)*$~', $key_c) === 1) {
$keys[$key_c][] = $where;
}
}
}
if ($this->is_dynamic($line)) {
$dynamic[] = $where;
}
}
}
}
ksort($keys);
return ['keys' => $keys, 'dynamic' => $dynamic, 'files_n' => $files_n];
}
/**
* What the code needs that a catalogue does not have, and the other way
* round.
*
* @param string[] $roots
*
* @return array{missing: array<string, string[]>, unused: string[], dynamic: string[], used_n: int, files_n: int}
*/
public function compare(array $roots, Catalog $catalog): array
{
$scan = $this->scan($roots);
$missing = [];
foreach ($scan['keys'] as $key_c => $where) {
if (!$catalog->has($key_c)) {
$missing[$key_c] = $where;
}
}
$unused = array_values(array_diff($catalog->keys(), array_keys($scan['keys'])));
sort($unused);
return [
'missing' => $missing,
'unused' => $unused,
'dynamic' => $scan['dynamic'],
'used_n' => count($scan['keys']),
'files_n' => $scan['files_n'],
];
}
/**
* Keys one catalogue has and another does not — the translator's worklist.
*
* @return string[]
*/
public static function untranslated(Catalog $source, Catalog $target): array
{
$missing = array_values(array_diff($source->keys(), $target->keys()));
sort($missing);
return $missing;
}
/**
* Keys whose message is identical in both catalogues.
*
* Usually one of two things, and both are worth seeing: a word that is
* genuinely the same in both languages, or a line somebody copied across and
* never translated. The tool cannot tell them apart and does not pretend to.
*
* @return string[]
*/
public static function identical(Catalog $a, Catalog $b): array
{
$same = [];
foreach ($a->all() as $key_c => $message) {
if ($b->get($key_c) === $message) {
$same[] = $key_c;
}
}
sort($same);
return $same;
}
/**
* @return string[]
*/
private function files_under(string $root): array
{
$real = realpath($root);
if ($real === false || !is_dir($real)) {
throw new I18nException("There is nothing to scan at: {$root}");
}
$out = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveCallbackFilterIterator(
new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS),
function (\SplFileInfo $item): bool {
return !$item->isDir() || !in_array($item->getFilename(), $this->skip_dirs, true);
}
)
);
foreach ($iterator as $item) {
if ($item->isFile()
&& in_array(strtolower($item->getExtension()), $this->extensions, true)) {
$out[] = (string) $item->getPathname();
}
}
sort($out);
return $out;
}
}