19declare(strict_types=1);
21use PHPUnit\Framework\Attributes\DataProvider;
22use PHPUnit\Framework\TestCase;
30 private const string TOOL = __DIR__ .
'/../tools/language-identifier-enum/generate_language_identifier_enum.php';
32 private const string POT =
'components/ILIAS/Demo/lang/demo.pot';
33 private const string ENUM =
'components/ILIAS/Demo/src/DemoIdentifier.php';
38 protected function setUp(): void
40 $this->directory = sys_get_temp_dir() .
'/ilias_key_enum_tool_' . bin2hex(random_bytes(4));
41 $this->root = $this->directory .
'/repo';
42 $tool_dir = $this->root .
'/components/ILIAS/Language/tools/language-identifier-enum';
43 mkdir($tool_dir, 0775,
true);
44 mkdir($this->root .
'/components/ILIAS/Demo/lang', 0775,
true);
45 mkdir($this->root .
'/components/ILIAS/Demo/src', 0775,
true);
46 mkdir($this->directory .
'/outside', 0775,
true);
47 copy(self::TOOL, $tool_dir .
'/generate_language_identifier_enum.php');
48 mkdir($this->root .
'/vendor/composer/vendor', 0775,
true);
49 $real_autoload = realpath(__DIR__ .
'/../../../../vendor/composer/vendor/autoload.php');
50 $this->assertIsString($real_autoload);
51 symlink($real_autoload, $this->root .
'/vendor/composer/vendor/autoload.php');
64 '-d',
'error_reporting=-1',
'-d',
'display_errors=stderr',
65 $this->root .
'/components/ILIAS/Language/tools/language-identifier-enum/generate_language_identifier_enum.php',
70 $this->assertNotNull($result,
'could not start the child PHP process');
76 private function writePot(array $messages,
string $header =
"\"X-Domain: demo\\n\"\n",
string $path = self::POT): void
78 $pot =
"msgid \"\"\nmsgstr \"\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n" . $header .
"\n";
80 if (str_starts_with(
$message,
'plural:')) {
82 $pot .=
"msgid \"$id\"\nmsgid_plural \"{$id}s\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\n";
83 } elseif (str_starts_with(
$message,
'ctx:')) {
85 $pot .=
"msgctxt \"$context\"\nmsgid \"$id\"\nmsgstr \"\"\n\n";
87 $pot .=
"msgid \"$message\"\nmsgstr \"\"\n\n";
90 file_put_contents($this->root .
'/' . $path, $pot);
96 return $this->
runTool(...$options, ...[
'--namespace=' . self::NAMESPACE, self::POT, self::ENUM]);
100 private function loadEnum(
string $file = self::ENUM,
string $class = self::NAMESPACE .
'\DemoIdentifier'): array
103 'require_once %s; require %s; $m = []; foreach (%s::cases() as $c) { $m[$c->name] = $c->value; } '
104 .
'echo json_encode(["module" => %s::cases() === [] ? "" : %s::cases()[0]->module(), "cases" => $m, "is_key" => is_a(%s::class, "ILIAS\\\\Language\\\\LanguageIdentifier", true)]);',
105 var_export((
string) realpath(__DIR__ .
'/../../../../vendor/composer/vendor/autoload.php'),
true),
106 var_export($this->root .
'/' . $file,
true),
112 file_put_contents($this->directory .
'/load.php',
'<?php ' . $code);
114 [
'-d',
'error_reporting=-1',
'-d',
'display_errors=stderr', $this->directory .
'/load.php']
116 $this->assertNotNull($result,
'could not start the child PHP process');
117 [$exit, $stdout, $stderr] = $result;
118 $this->assertSame(0, $exit, $stderr);
119 $this->assertSame(
'', $stderr,
'no warning or deprecation');
120 $decoded = json_decode($stdout,
true, 512, JSON_THROW_ON_ERROR);
121 $this->assertTrue($decoded[
'is_key']);
123 return [
'module' => $decoded[
'module'],
'cases' => $decoded[
'cases']];
129 'poll_answers',
'a-b.c',
'1st',
'class',
'Class',
'style',
'Style',
'STYLE',
'a b',
'a-b',
130 'plural:item',
'ctx:demo:with_context',
'Ünï',
'x_2',
'x',
'X',
133 [$exit, $stdout, $stderr] = $this->
generate();
135 $this->assertSame(0, $exit, $stderr);
136 $this->assertStringContainsString(
'Written:', $stdout);
137 $this->assertStringContainsString(
'NOTE: Case name STYLE: "style" -> STYLE, "STYLE" -> STYLE_2, "Style" -> STYLE_3', $stderr);
138 $this->assertStringContainsString(
'NOTE: Case name KEY_CLASS: "class" -> KEY_CLASS, "Class" -> KEY_CLASS_2', $stderr);
140 $this->assertSame(
'demo', $loaded[
'module']);
141 $this->assertEquals([
142 'POLL_ANSWERS' =>
'poll_answers',
145 'KEY_CLASS' =>
'class',
146 'KEY_CLASS_2' =>
'Class',
148 'STYLE_2' =>
'STYLE',
149 'STYLE_3' =>
'Style',
153 'WITH_CONTEXT' =>
'with_context',
158 ], $loaded[
'cases']);
163 $identifiers = [
'one',
'Two',
'three_3',
'4four',
'class',
'CLASS',
'a-a',
'a_a',
'A.A',
'plural_message'];
164 $this->
writePot(array_map(
static fn(
string $i):
string => $i ===
'plural_message' ?
'plural:plural_message' : $i, $identifiers));
166 [$exit, , $stderr] = $this->
generate();
168 $this->assertSame(0, $exit, $stderr);
169 $cases = $this->
loadEnum()[
'cases'];
170 $this->assertCount(count($identifiers), $cases);
171 $values = array_values($cases);
173 $expected = $identifiers;
175 $this->assertSame($expected, $values,
'no identifier lost, none doubled, the msgid_plural is no case');
176 foreach (array_keys($cases) as $name) {
177 $this->assertMatchesRegularExpression(
'/^[A-Z_][A-Z0-9_]*$/', $name);
184 [, $stdout] = $this->
generate(
'--stdout');
185 $this->assertFileDoesNotExist($this->root .
'/' . self::ENUM,
'--stdout writes nothing');
187 $this->assertSame(0, $this->
generate()[0]);
188 $this->assertSame($stdout, (
string) file_get_contents($this->root .
'/' . self::ENUM));
189 $before = fileperms($this->root .
'/' . self::ENUM);
190 [$exit, $again] = $this->
generate();
192 $this->assertSame(0, $exit);
193 $this->assertStringContainsString(
'Unchanged:', $again);
194 $this->assertSame($before, fileperms($this->root .
'/' . self::ENUM));
195 $this->assertSame([
'.',
'..',
'DemoIdentifier.php'], scandir($this->root .
'/components/ILIAS/Demo/src'),
'no temporary file left');
201 yield
'option wins' => [
'--module=from_option',
"\"X-Domain: from_header\\n\"\n",
'from_option'];
202 yield
'X-Domain header' => [
null,
"\"X-Domain: from_header\\n\"\n",
'from_header'];
203 yield
'file name' => [
null,
'',
'demo'];
206 #[DataProvider('modules')]
211 [$exit, , $stderr] = $this->
runTool(...array_filter([$option,
'--namespace=' . self::NAMESPACE, self::POT, self::ENUM]));
213 $this->assertSame(0, $exit, $stderr);
214 $this->assertSame($expected, $this->
loadEnum()[
'module']);
221 [$exit, , $stderr] = $this->
generate();
223 $this->assertSame(0, $exit, $stderr);
224 $this->assertSame([], $this->
loadEnum()[
'cases']);
225 $this->assertSame(0, $this->
generate(
'--check')[0]);
232 [$exit, $stdout] = $this->
runTool(
'--stdout',
'--namespace=ILIAS\Language', self::POT,
'components/ILIAS/Demo/src/DemoIdentifier.php');
234 $this->assertSame(0, $exit);
235 $this->assertStringContainsString(
"namespace ILIAS\\Language;\n", $stdout);
236 $this->assertStringNotContainsString(
'use ILIAS\\Language\\LanguageIdentifier;', $stdout,
'no import of its own namespace');
237 $this->assertStringContainsString(
'enum DemoIdentifier: string implements LanguageIdentifier', $stdout);
242 $this->
writePot([
'one',
'plural:two',
'Style',
'style']);
245 [$exit, $stdout, $stderr] = $this->
generate(
'--check');
247 $this->assertSame(0, $exit, $stderr);
248 $this->assertStringContainsString(
'OK:', $stdout);
254 yield
'surplus case' => [
static fn(
string $code):
string => str_replace(
" public function module",
" case EXTRA = 'extra';\n\n public function module", $code),
'Surplus case EXTRA'];
255 yield
'missing case' => [
static fn(
string $code):
string => str_replace(
" case TWO = 'two';\n",
'', $code),
'Missing case TWO'];
256 yield
'wrong value' => [
static fn(
string $code):
string => str_replace(
"case TWO = 'two'",
"case TWO = 'zwei'", $code),
'Case TWO has the value'];
257 yield
'wrong module' => [
static fn(
string $code):
string => str_replace(
"return 'demo';",
"return 'other';", $code),
'module() returns'];
258 yield
'not a LanguageIdentifier' => [
static fn(
string $code):
string => str_replace(
' implements LanguageIdentifier',
'', $code),
'does not implement'];
259 yield
'not string backed' => [
static fn(
string $code):
string => (string) preg_replace(
"/case (\w+) = '[^']*';/",
'case $1 = 1;', str_replace(
'enum DemoIdentifier: string',
'enum DemoIdentifier: int', $code)),
'not backed by string'];
263 #[DataProvider('deviations')]
269 $broken = $break((
string) file_get_contents($file));
270 $this->assertNotSame(file_get_contents($file), $broken,
'the fixture really breaks the enum');
271 file_put_contents($file, $broken);
273 [$exit, , $stderr] = $this->
generate(
'--check');
275 $this->assertSame(1, $exit, $stderr);
276 $this->assertStringContainsString(
'DEVIATION: ', $stderr);
277 $this->assertStringContainsString(
$message, $stderr);
284 $this->
writePot([
'one',
'two',
'three']);
286 [$exit, , $stderr] = $this->
generate(
'--check');
288 $this->assertSame(1, $exit);
289 $this->assertStringContainsString(
'Missing case THREE', $stderr);
296 [$exit, , $stderr] = $this->
generate(
'--check');
298 $this->assertSame(2, $exit);
299 $this->assertStringContainsString(
'does not exist', $stderr);
300 $this->assertFileDoesNotExist($this->root .
'/' . self::ENUM);
306 file_put_contents($this->root .
'/' . self::ENUM,
"<?php\nnamespace Other;\nenum DemoIdentifier: string { case ONE = 'one'; }\n");
308 [$exit, , $stderr] = $this->
generate(
'--check');
310 $this->assertSame(2, $exit);
311 $this->assertStringContainsString(
'does not declare the enum', $stderr);
317 yield
'no namespace' => [[
'POT',
'ENUM'],
'Usage:'];
318 yield
'one path missing' => [[
'NS',
'POT'],
'Usage:'];
319 yield
'three paths' => [[
'NS',
'POT',
'ENUM',
'ENUM'],
'Usage:'];
320 yield
'unknown option' => [[
'NS',
'--force',
'POT',
'ENUM'],
'Unknown option'];
321 yield
'check and stdout' => [[
'NS',
'--check',
'--stdout',
'POT',
'ENUM'],
'exclude each other'];
322 yield
'leading backslash' => [[
'--namespace=\ILIAS\Demo',
'POT',
'ENUM'],
'Invalid namespace'];
323 yield
'empty namespace' => [[
'--namespace=',
'POT',
'ENUM'],
'Invalid namespace'];
324 yield
'digit namespace' => [[
'--namespace=ILIAS\1Demo',
'POT',
'ENUM'],
'Invalid namespace'];
325 yield
'namespace with space' => [[
'--namespace=ILIAS Demo',
'POT',
'ENUM'],
'Invalid namespace'];
326 yield
'namespace with code' => [[
'--namespace=A;eval($x)',
'POT',
'ENUM'],
'Invalid namespace'];
327 yield
'double backslash' => [[
'--namespace=ILIAS\\\\Demo',
'POT',
'ENUM'],
'Invalid namespace'];
328 yield
'module with path' => [[
'NS',
'--module=../x',
'POT',
'ENUM'],
'Invalid module'];
329 yield
'module with space' => [[
'NS',
'--module=a b',
'POT',
'ENUM'],
'Invalid module'];
330 yield
'empty module' => [[
'NS',
'--module=',
'POT',
'ENUM'],
'Invalid module'];
331 yield
'enum name with dash' => [[
'NS',
'POT',
'components/ILIAS/Demo/src/a-b.php'],
'no valid enum name'];
332 yield
'enum name with digit first' => [[
'NS',
'POT',
'components/ILIAS/Demo/src/1Key.php'],
'no valid enum name'];
333 yield
'enum no php' => [[
'NS',
'POT',
'components/ILIAS/Demo/src/DemoIdentifier.txt'],
'no .php file'];
334 yield
'enum directory missing' => [[
'NS',
'POT',
'components/ILIAS/Demo/nowhere/DemoIdentifier.php'],
'does not exist'];
335 yield
'pot missing' => [[
'NS',
'components/ILIAS/Demo/lang/none.pot',
'ENUM'],
'no .pot file'];
336 yield
'pot is no pot' => [[
'NS',
'components/ILIAS/Demo/lang/demo.txt',
'ENUM'],
'no .pot file'];
337 yield
'pot outside by ..' => [[
'NS',
'components/../../outside/x.pot',
'ENUM'],
'no .pot file'];
338 yield
'enum outside by ..' => [[
'NS',
'POT',
'components/../../outside/DemoIdentifier.php'],
'no .php file path'];
339 yield
'enum with .. inside missing part' => [[
'NS',
'POT',
'components/ILIAS/Demo/new/../src/DemoIdentifier.php'],
'no .php file path'];
340 yield
'enum absolute outside' => [[
'NS',
'POT',
'/tmp/DemoIdentifier.php'],
'no .php file path'];
341 yield
'enum is the root' => [[
'NS',
'POT',
'.php'],
'is no valid enum name'];
342 yield
'pot through a symlink out' => [[
'NS',
'linked/outside.pot',
'ENUM'],
'no .pot file'];
343 yield
'enum through a symlink out' => [[
'NS',
'POT',
'linked/DemoIdentifier.php'],
'no .php file path'];
344 yield
'enum is a dangling symlink out' => [[
'NS',
'POT',
'components/ILIAS/Demo/src/Dangling.php'],
'Refusing|no .php file path'];
348 #[DataProvider('invalidInput')]
352 file_put_contents($this->directory .
'/outside/outside.pot',
"msgid \"\"\nmsgstr \"\"\n\nmsgid \"x\"\nmsgstr \"\"\n");
353 symlink($this->directory .
'/outside', $this->root .
'/linked');
354 symlink($this->directory .
'/outside/target.php', $this->root .
'/components/ILIAS/Demo/src/Dangling.php');
355 $arguments = array_map(
static fn(
string $a):
string => match (
$a) {
362 [$exit, $stdout, $stderr] = $this->
runTool(...$arguments);
364 $this->assertSame(2, $exit, $stdout . $stderr);
365 $this->assertMatchesRegularExpression(
'/ERROR: .*(' . $stderr_pattern .
')/', $stderr);
366 $this->assertSame([], glob($this->directory .
'/outside/*.php') ?: []);
368 [$this->root .
'/components/ILIAS/Demo/src/Dangling.php'],
369 glob($this->root .
'/components/ILIAS/Demo/src/*') ?: [],
376 $this->
writePot([
'one',
'ctx:other:two']);
378 [$exit, , $stderr] = $this->
generate();
380 $this->assertSame(2, $exit);
381 $this->assertStringContainsString(
'msgctxt "other"', $stderr);
382 $this->assertFileDoesNotExist($this->root .
'/' . self::ENUM);
387 $this->
writePot([
'one'],
"\"X-Domain: ../../x\\n\"\n");
389 [$exit, , $stderr] = $this->
generate();
391 $this->assertSame(2, $exit);
392 $this->assertStringContainsString(
'Invalid module', $stderr);
399 [$exit, , $stderr] = $this->
runTool(
"--namespace=A\x1b[31mB", self::POT, self::ENUM);
401 $this->assertSame(2, $exit);
402 $this->assertStringNotContainsString(
"\x1b", $stderr);
403 $this->assertStringContainsString(
'\x1B', $stderr);
409 yield
'asterisk' => [
'a*b/demo.pot'];
410 yield
'space' => [
'a b/demo.pot'];
411 yield
'comment end' => [
'x*/y/demo.pot'];
412 yield
'code in directory names' => [
"x*/system('echo PWNED');/*/demo.pot"];
413 yield
'quote' => [
"it's/demo.pot"];
416 #[DataProvider('unsafePotPaths')]
419 $path =
'components/ILIAS/Demo/lang/' . $relative;
420 mkdir(dirname($this->root .
'/' . $path), 0775,
true);
421 $this->
writePot([
'one'],
"\"X-Domain: demo\\n\"\n", $path);
423 [$exit, $stdout, $stderr] = $this->
runTool(
'--namespace=' . self::NAMESPACE, $path, self::ENUM);
425 $this->assertSame(2, $exit, $stdout . $stderr);
426 $this->assertStringContainsString(
'may only contain', $stderr);
427 $this->assertStringNotContainsString(
"PWNED\n", $stdout,
'nothing is executed');
428 $this->assertFileDoesNotExist($this->root .
'/' . self::ENUM);
429 [$exit_stdout,
$out] = $this->
runTool(
'--stdout',
'--namespace=' . self::NAMESPACE, $path, self::ENUM);
430 $this->assertSame(2, $exit_stdout);
431 $this->assertSame(
'',
$out,
'--stdout prints nothing either');
436 $this->
writePot([
'a*/b',
'*/',
"/* x",
'it\'s',
'ok']);
438 [$exit, , $stderr] = $this->
generate();
440 $this->assertSame(0, $exit, $stderr);
443 $this->assertNotNull($lint,
'could not start the child PHP process');
444 $this->assertSame(0, $lint[0], $lint[1] . $lint[2]);
445 $this->assertEqualsCanonicalizing([
'a*/b',
'*/',
'/* x',
"it's",
'ok'], array_values($this->
loadEnum()[
'cases']));
446 [$check_exit, , $check_stderr] = $this->
generate(
'--check');
447 $this->assertSame(0, $check_exit, $check_stderr);
452 $this->
writePot([
'__halt_compiler',
'class',
'CLASS',
'Class']);
454 [$exit, , $stderr] = $this->
generate();
456 $this->assertSame(0, $exit, $stderr);
457 $cases = $this->
loadEnum()[
'cases'];
458 $this->assertSame(
'__halt_compiler', $cases[
'KEY___HALT_COMPILER'] ??
null);
459 $this->assertSame(
'class', $cases[
'KEY_CLASS'] ??
null);
460 $this->assertEqualsCanonicalizing([
'KEY___HALT_COMPILER',
'KEY_CLASS',
'KEY_CLASS_2',
'KEY_CLASS_3'], array_keys($cases));
469 yield
'the module' => [
"return 'demo';", 0,
'OK:'];
470 yield
'a comment in between' => [
"/* c */ return 'demo'; // x", 0,
'OK:'];
471 yield
'another module' => [
"return 'other';", 1,
'module() returns'];
472 yield
'concatenation' => [
"return 'de' . 'mo';", 1,
'does not just return'];
473 yield
'call' => [
"return strtolower('DEMO');", 1,
'does not just return'];
474 yield
'double quoted' => [
'return "demo";', 1,
'does not just return'];
475 yield
'constant' => [
'return self::class;', 1,
'does not just return'];
476 yield
'more than the return' => [
"echo 'x'; return 'demo';", 1,
'does not just return'];
477 yield
'second statement' => [
"return 'demo'; echo 'x';", 1,
'does not just return'];
480 #[DataProvider('modulesOfAnEnumWithoutCases')]
486 $code = (string) file_get_contents($file);
487 $changed = str_replace(
"return 'demo';",
$body, $code);
488 $this->assertNotSame(
'', $changed);
489 file_put_contents($file, $changed);
491 [$exit, $stdout, $stderr] = $this->
generate(
'--check');
493 $this->assertSame($expected_exit, $exit, $stdout . $stderr);
494 $this->assertStringContainsString(
$message, $stdout . $stderr);
502 file_put_contents($file, str_replace(
"return 'demo';",
"echo 'PWNED'; return 'demo';", (
string) file_get_contents($file)));
504 [$exit, $stdout] = $this->
generate(
'--check');
506 $this->assertSame(1, $exit);
507 $this->assertStringNotContainsString(
'PWNED', $stdout);
514 [$exit, $stdout, $stderr] = $this->
generate();
516 $this->assertSame(2, $exit);
517 $this->assertStringNotContainsString(
"\x1b", $stdout . $stderr);
518 $this->assertFileDoesNotExist($this->root .
'/' . self::ENUM);
$id
plugin.php for ilComponentBuildPluginInfoObjectiveTest::testAddPlugins
static run(array $arguments, ?string $working_directory=null)
static removeDirectory(string $directory)
$a
thx to https://mlocati.github.io/php-cs-fixer-configurator for the examples