ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
generate_language_identifier_enum.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
49// a development tool: never reachable through the web server
50if (PHP_SAPI !== 'cli') {
51 exit(1);
52}
53
54$repo_root = dirname(__DIR__, 5);
55require $repo_root . '/vendor/composer/vendor/autoload.php';
56
61
62const LANGUAGE_IDENTIFIER_ENUM_TOOL = 'components/ILIAS/Language/tools/language-identifier-enum/generate_language_identifier_enum.php';
63
64$fail = static function (string $message): never {
65 fwrite(STDERR, 'ERROR: ' . $message . PHP_EOL);
66 exit(2);
67};
68
72$printable = static fn(string $text): string => (string) preg_replace_callback(
73 '/[\x00-\x1F\x7F]/',
74 static fn(array $match): string => sprintf('\x%02X', ord($match[0])),
75 $text
76);
77
78$identifier_format = '/^[A-Za-z_][A-Za-z0-9_]*$/';
79
83$reserved_case_names = ['CLASS', '__HALT_COMPILER'];
84
85$comment_safe = static fn(string $text): string => str_replace('*/', '*\\/', $text);
86
91$inside_repository = static function (string $path, string $root): ?string {
92 if ($path === '' || str_contains($path, "\0")) {
93 return null;
94 }
95 if (!str_starts_with($path, '/')) {
96 $path = getcwd() . '/' . $path;
97 }
98 $missing = [];
99 $existing = rtrim($path, '/');
100 while ($existing !== '' && !file_exists($existing)) {
101 $missing[] = basename($existing);
102 $existing = dirname($existing);
103 }
104 $resolved = realpath($existing === '' ? '/' : $existing);
105 $resolved_root = realpath($root);
106 if ($resolved === false || $resolved_root === false) {
107 return null;
108 }
109 foreach ($missing as $segment) {
110 if ($segment === '' || $segment === '.' || $segment === '..') {
111 return null;
112 }
113 }
114 $absolute = $resolved . ($missing === [] ? '' : '/' . implode('/', array_reverse($missing)));
115
116 return str_starts_with($absolute . '/', rtrim($resolved_root, '/') . '/') && $absolute !== $resolved_root
117 ? $absolute
118 : null;
119};
120
125$case_names = static function (array $identifiers) use ($reserved_case_names): array {
126 $bases = [];
127 foreach ($identifiers as $identifier) {
128 $base = (string) preg_replace('/[^A-Z0-9_]+/', '_', strtoupper($identifier));
129 if (preg_match('/^[0-9]/', $base) === 1 || in_array($base, $reserved_case_names, true)) {
130 $base = 'KEY_' . $base;
131 }
132 $bases[$identifier] = $base;
133 }
134 $groups = [];
135 foreach ($bases as $identifier => $base) {
136 $groups[$base][] = (string) $identifier;
137 }
138 $taken = array_fill_keys(array_keys($groups), true);
139 $names = [];
140 $collisions = [];
141 ksort($groups, SORT_STRING);
142 foreach ($groups as $base => $group) {
143 usort($group, static fn(string $a, string $b): int => [$a !== strtolower($a), $a] <=> [$b !== strtolower($b), $b]);
144 $names[$group[0]] = (string) $base;
145 $described = [sprintf('"%s" -> %s', $group[0], $base)];
146 $suffix = 2;
147 foreach (array_slice($group, 1) as $identifier) {
148 while (isset($taken[$base . '_' . $suffix])) {
149 $suffix++;
150 }
151 $name = $base . '_' . $suffix;
152 $taken[$name] = true;
153 $names[$identifier] = $name;
154 $described[] = sprintf('"%s" -> %s', $identifier, $name);
155 }
156 if (count($group) > 1) {
157 $collisions[] = sprintf('Case name %s: %s', $base, implode(', ', $described));
158 }
159 }
160
161 $ordered = [];
162 foreach ($identifiers as $identifier) {
163 $ordered[$identifier] = $names[$identifier];
164 }
165
166 return ['names' => $ordered, 'collisions' => $collisions];
167};
168
172$render = static function (
173 string $namespace,
174 string $enum_name,
175 string $module,
176 string $pot_path,
177 array $names
178) use ($comment_safe): string {
179 $code = <<<'PHP'
180 <?php
181
198 declare(strict_types=1);
199
200 PHP;
201 $code .= "\nnamespace " . $namespace . ";\n\n";
202 if ($namespace !== 'ILIAS\\Language') {
203 $code .= "use ILIAS\\Language\\LanguageIdentifier;\n\n";
204 }
205 $code .= "/**\n"
206 . " * The language identifiers of the module \"" . $comment_safe($module) . "\", see LanguageIdentifier.\n"
207 . " *\n"
208 . " * Generated from " . $comment_safe($pot_path) . "\n"
209 . " * by " . LANGUAGE_IDENTIFIER_ENUM_TOOL . " - do not edit;\n"
210 . " * generate it again after every change of the .pot (CI: --check).\n"
211 . " */\n"
212 . "enum " . $enum_name . ": string implements LanguageIdentifier\n{\n";
213 foreach ($names as $identifier => $name) {
214 $code .= ' case ' . $name . ' = ' . var_export((string) $identifier, true) . ";\n";
215 }
216 if ($names !== []) {
217 $code .= "\n";
218 }
219 $code .= " public function module(): string\n {\n return " . var_export($module, true) . ";\n }\n}\n";
220
221 return $code;
222};
223
227$literal_module_of = static function (ReflectionEnum $reflection): ?string {
228 if (!$reflection->hasMethod('module')) {
229 return null;
230 }
231 $method = $reflection->getMethod('module');
232 $file = $method->getFileName();
233 $lines = $file === false ? false : file($file);
234 if ($lines === false || $method->getStartLine() === false || $method->getEndLine() === false) {
235 return null;
236 }
237 $source = implode('', array_slice($lines, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
238 $tokens = array_values(array_filter(
239 token_get_all('<?php ' . $source),
240 static fn(mixed $token): bool => !is_array($token) || !in_array($token[0], [T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)
241 ));
242 $start = array_search('{', $tokens, true);
243 $body = $start === false ? [] : array_slice($tokens, $start + 1);
244 if (
245 count($body) !== 4
246 || !is_array($body[0]) || $body[0][0] !== T_RETURN
247 || !is_array($body[1]) || $body[1][0] !== T_CONSTANT_ENCAPSED_STRING || !str_starts_with($body[1][1], "'")
248 || $body[2] !== ';' || $body[3] !== '}'
249 ) {
250 return null;
251 }
252
253 return strtr(substr($body[1][1], 1, -1), ['\\\\' => '\\', "\\'" => "'"]);
254};
255
256// --- arguments ---
257
258$options = ['check' => false, 'stdout' => false, 'module' => null, 'namespace' => null];
259$arguments = [];
260foreach (array_slice($argv, 1) as $argument) {
261 if ($argument === '--check' || $argument === '--stdout') {
262 $options[substr($argument, 2)] = true;
263 } elseif (str_starts_with($argument, '--module=')) {
264 $options['module'] = substr($argument, strlen('--module='));
265 } elseif (str_starts_with($argument, '--namespace=')) {
266 $options['namespace'] = substr($argument, strlen('--namespace='));
267 } elseif (str_starts_with($argument, '-')) {
268 $fail(sprintf('Unknown option "%s".', $printable($argument)));
269 } else {
270 $arguments[] = $argument;
271 }
272}
273if (count($arguments) !== 2 || $options['namespace'] === null) {
274 $fail('Usage: php generate_language_identifier_enum.php [--check | --stdout] [--module=<module>] --namespace=<Namespace> <pot file> <enum file>');
275}
276if ($options['check'] && $options['stdout']) {
277 $fail('--check and --stdout exclude each other.');
278}
279[$pot_argument, $enum_argument] = $arguments;
280
281$namespace = (string) $options['namespace'];
282if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$/', $namespace) !== 1) {
283 $fail(sprintf('Invalid namespace "%s" (e.g. "ILIAS\\Poll", without leading backslash).', $printable($namespace)));
284}
285
286$pot_file = $inside_repository($pot_argument, $repo_root);
287if ($pot_file === null || !is_file($pot_file) || !str_ends_with($pot_file, '.pot')) {
288 $fail(sprintf('"%s" is no .pot file inside %s.', $printable($pot_argument), $printable($repo_root)));
289}
290$enum_file = $inside_repository($enum_argument, $repo_root);
291if ($enum_file === null || !str_ends_with($enum_file, '.php')) {
292 $fail(sprintf('"%s" is no .php file path inside %s.', $printable($enum_argument), $printable($repo_root)));
293}
294$enum_name = basename($enum_file, '.php');
295if (preg_match($identifier_format, $enum_name) !== 1) {
296 $fail(sprintf('"%s" is no valid enum name (the file name without ".php").', $printable($enum_name)));
297}
298if ($options['check'] && !is_file($enum_file)) {
299 $fail(sprintf('The enum "%s" does not exist.', $printable($enum_argument)));
300}
301if (!$options['check'] && !$options['stdout'] && !is_dir(dirname($enum_file))) {
302 $fail(sprintf('The directory of "%s" does not exist.', $printable($enum_argument)));
303}
304
305try {
306 $catalog = TranslationCatalog::fromPoFile($pot_file);
307} catch (RuntimeException $e) {
308 $fail($printable($e->getMessage()));
309}
310$module = $options['module'] ?? $catalog->getHeader('X-Domain') ?? basename($pot_file, '.pot');
311if (!MigratedLanguageFilePaths::isValidModule($module)) {
312 $fail(sprintf('Invalid module "%s".', $printable($module)));
313}
314
315$identifiers = [];
316foreach ($catalog->getEntries() as $entry) {
317 $context = $entry->getContext();
318 if ($context !== null && $context !== $module) {
319 $fail(sprintf('msgid "%s" has the msgctxt "%s" - only none or the module is supported.', $printable($entry->getId()), $printable($context)));
320 }
321 $identifier = $entry->getId();
322 if ($identifier === '' || preg_match('/[\x00-\x1F\x7F]/', $identifier) === 1) {
323 $fail(sprintf('msgid "%s" cannot be a case (empty or with a control character).', $printable($identifier)));
324 }
325 $identifiers[$identifier] = $identifier;
326}
327['names' => $names, 'collisions' => $collisions] = $case_names(array_values($identifiers));
328foreach ($collisions as $collision) {
329 fwrite(STDERR, 'NOTE: ' . $printable($collision) . PHP_EOL);
330}
331
332$pot_path = substr($pot_file, strlen(rtrim((string) realpath($repo_root), '/')) + 1);
333// the path goes into a doc comment of the generated code: nothing but plain path characters
334if (preg_match('/\A[A-Za-z0-9_.\/-]+\z/', $pot_path) !== 1) {
335 $fail(sprintf('The path "%s" of the .pot may only contain A-Z, a-z, 0-9, "_", ".", "/" and "-".', $printable($pot_path)));
336}
337$fqcn = $namespace . '\\' . $enum_name;
338
339// --- check ---
340
341if ($options['check']) {
342 if (!interface_exists(LanguageIdentifier::class)) {
343 require_once dirname(__DIR__, 2) . '/src/LanguageIdentifier.php';
344 }
345 $declared_before = get_declared_classes();
346 (static function (string $file): void {
347 require $file;
348 })($enum_file);
349 $declared = array_map('strtolower', array_diff(get_declared_classes(), $declared_before));
350 if (!in_array(strtolower($fqcn), $declared, true) || !enum_exists($fqcn, false)) {
351 $fail(sprintf('"%s" does not declare the enum %s.', $printable($enum_argument), $printable($fqcn)));
352 }
353 $reflection = new ReflectionEnum($fqcn);
354 $deviations = [];
355 if (!$reflection->implementsInterface(LanguageIdentifier::class)) {
356 $deviations[] = sprintf('%s does not implement %s.', $fqcn, LanguageIdentifier::class);
357 }
358 if ((string) $reflection->getBackingType() !== 'string') {
359 $deviations[] = sprintf('%s is not backed by string.', $fqcn);
360 }
361 $actual = [];
362 foreach ($reflection->getCases() as $case) {
363 $actual[$case->getName()] = $case instanceof ReflectionEnumBackedCase ? $case->getBackingValue() : null;
364 }
365 $expected = array_flip($names);
366 foreach ($expected as $name => $identifier) {
367 if (!array_key_exists($name, $actual)) {
368 $deviations[] = sprintf('Missing case %s = %s.', $name, var_export((string) $identifier, true));
369 } elseif ($actual[$name] !== (string) $identifier) {
370 $deviations[] = sprintf('Case %s has the value %s instead of %s.', $name, var_export($actual[$name], true), var_export((string) $identifier, true));
371 }
372 }
373 foreach (array_diff_key($actual, $expected) as $name => $value) {
374 $deviations[] = sprintf('Surplus case %s = %s (not in the .pot).', $name, var_export($value, true));
375 }
376 $first_case = $fqcn::cases()[0] ?? null;
377 $actual_module = $first_case instanceof LanguageIdentifier
378 ? $first_case->module()
379 : $literal_module_of($reflection);
380 if ($actual_module === null) {
381 $deviations[] = sprintf('module() of %s (without cases) does not just return a single-quoted string literal - cannot check it.', $fqcn);
382 } elseif ($actual_module !== $module) {
383 $deviations[] = sprintf('module() returns %s instead of %s.', var_export($actual_module, true), var_export($module, true));
384 }
385 if ($deviations === []) {
386 fwrite(STDOUT, $printable(sprintf('OK: %s matches %s (%d cases, module "%s").', $fqcn, $pot_path, count($names), $module)) . PHP_EOL);
387 exit(0);
388 }
389 foreach ($deviations as $deviation) {
390 fwrite(STDERR, 'DEVIATION: ' . $printable($deviation) . PHP_EOL);
391 }
392 fwrite(STDERR, $printable(sprintf('%s deviates from %s - generate it again with %s.', $fqcn, $pot_path, LANGUAGE_IDENTIFIER_ENUM_TOOL)) . PHP_EOL);
393 exit(1);
394}
395
396// --- generate ---
397
398$code = $render($namespace, $enum_name, $module, $pot_path, $names);
399if ($options['stdout']) {
400 fwrite(STDOUT, $code);
401 exit(0);
402}
403if (is_file($enum_file) && file_get_contents($enum_file) === $code) {
404 fwrite(STDOUT, $printable(sprintf('Unchanged: %s (%d cases).', $enum_file, count($names))) . PHP_EOL);
405 exit(0);
406}
407try {
408 AtomicFileWriter::write($enum_file, $code, $repo_root);
409} catch (RuntimeException $e) {
410 $fail($printable($e->getMessage()));
411}
412fwrite(STDOUT, $printable(sprintf('Written: %s (%d cases, module "%s").', $enum_file, count($names), $module)) . PHP_EOL);
Writes a file so that concurrent readers only see the complete old or new content: a temporary file i...
An in-memory gettext catalog (header block plus messages) and the only place of the component that re...
The single place that knows where the files of a module migrated to PO/MO live:
if(!isset($arguments[0])) if(count( $arguments) > 3) if(! $remove_from_lang &&isset($arguments[2]) &&!is_dir($arguments[2])) $module
if(is_file($english_lang_file)) if( $language_names===[]) foreach(array_keys($per_language) as $lang_key) $pot_path
if(PHP_SAPI !=='cli') $repo_root
Generates the enum of the language identifiers of one module (an implementation of ILIAS\Language\Lan...
const LANGUAGE_IDENTIFIER_ENUM_TOOL
$reserved_case_names
Case names PHP rejects (all other keywords are allowed as names of class constants and cases).
$inside_repository
The absolute path of $path if it lies inside $root, null otherwise.
$printable
Makes control characters visible in everything printed that comes from a file or the command line.
A language identifier as an enum case (backing value = identifier), generated per module from its ....
$body
Definition: ltiauth.php:41
$a
thx to https://mlocati.github.io/php-cs-fixer-configurator for the examples
$text
Definition: xapiexit.php:21
$token
Definition: xapitoken.php:69
exit
This file is part of ILIAS, a powerful learning management system published by ILIAS open source e-Le...