1<?php
2
3/**
4 * Language file tests inspired by the script by schplurtz
5 * @link https://www.dokuwiki.org/teams:i18n:translation-check
6 */
7class lang_test extends DokuWikiTest
8{
9    /**
10     * returen all languages except english
11     *
12     * @return string[]
13     */
14    protected function findLanguages()
15    {
16        $languages = glob(DOKU_INC . 'inc/lang/*', GLOB_ONLYDIR);
17        $languages = array_map('basename', $languages);
18        $languages = array_filter($languages, function ($in) {
19            return $in !== 'en';
20        });
21        return $languages;
22    }
23
24    /**
25     * Get all installed plugins
26     *
27     * This finds all things that might be a plugin and does not care for enabled or not.
28     *
29     * @return string[]
30     */
31    protected function findPlugins()
32    {
33        $plugins = glob(DOKU_INC . 'lib/plugins/*', GLOB_ONLYDIR);
34        return $plugins;
35    }
36
37    /**
38     * Get all installed templates
39     *
40     * This finds all things that might be a template and does not care for enabled or not.
41     *
42     * @return string[]
43     */
44    protected function findTemplates()
45    {
46        $templates = glob(DOKU_INC . 'lib/tpl/*', GLOB_ONLYDIR);
47        return $templates;
48    }
49
50    /**
51     * Load the strings for the given language
52     *
53     * @param string $lang
54     * @return array
55     */
56    protected function loadLanguage($file)
57    {
58        $lang = [];
59        if (file_exists($file)) {
60            include $file;
61        }
62        return $lang;
63    }
64
65    /**
66     * Provide all the language files to compare
67     *
68     * @return Generator
69     */
70    public function provideLanguageFiles()
71    {
72        $bases = array_merge(
73            [DOKU_INC . 'inc'],
74            $this->findPlugins(),
75            $this->findTemplates()
76        );
77
78        foreach ($this->findLanguages() as $code) {
79            foreach ($bases as $base) {
80                foreach (['lang.php', 'settings.php'] as $file) {
81                    $englishFile = "$base/lang/en/$file";
82                    $foreignFile = "$base/lang/$code/$file";
83                    $name = substr($foreignFile, strlen(DOKU_INC));
84                    $name = '…'.substr($name, -35);
85
86                    if (file_exists($foreignFile)) {
87                        yield ([
88                            $this->loadLanguage($englishFile),
89                            $this->loadLanguage($foreignFile),
90                            $code,
91                            $name,
92                        ]);
93                    }
94                }
95            }
96        }
97    }
98
99    /**
100     * Check for obsolete language strings
101     *
102     * @param array $english key/value language pairs for English
103     * @param array $foreign key/value language pairs for the foreign language
104     * @param string $code language code of the foreign file
105     * @param string $file the base file name the foreign keys came from
106     * @param string $prefix sub key that is currently checked (used in recursion)
107     * @dataProvider provideLanguageFiles
108     */
109    public function testObsolete($english, $foreign, $code, $file, $prefix = '')
110    {
111        $this->assertGreaterThan(0, count($foreign), "$file exists but has no translations");
112
113        foreach ($foreign as $key => $value) {
114            $name = $prefix ? $prefix . $key : $key;
115            $this->assertArrayHasKey($key, $english, "$file: obsolete/unknown key '$name'");
116
117            // sub arrays as for the js translations:
118            if (is_array($value) && is_array($english[$key])) {
119                $this->testObsolete($english[$key], $value, $code, $file, $key);
120            }
121        }
122    }
123
124    /**
125     * Check for sprintf format placeholder equality
126     *
127     * @param array $english key/value language pairs for English
128     * @param array $foreign key/value language pairs for the foreign language
129     * @param string $code language code of the foreign file
130     * @param string $file the base file name the foreign keys came from
131     * @param string $prefix sub key that is currently checked (used in recursion)
132     * @dataProvider provideLanguageFiles
133     */
134    public function testPlaceholders($english, $foreign, $code, $file, $prefix = '')
135    {
136        $this->assertGreaterThan(0, count($foreign), "$file exists but has no translations");
137
138        foreach ($foreign as $key => $value) {
139            // non existing in english is skipped here, that what testObsolete checks
140            if (!isset($english[$key])) continue;
141
142            // sub arrays as for the js translations:
143            if (is_array($value) && is_array($english[$key])) {
144                $this->testPlaceholders($english[$key], $value, $code, $file, $key);
145                return;
146            }
147
148            $name = $prefix ? $prefix . $key : $key;
149
150            $englishPlaceholders = $this->parsePlaceholders($english[$key]);
151            $foreignPlaceholders = $this->parsePlaceholders($value);
152            $countEnglish = count($englishPlaceholders);
153            $countForeign = count($foreignPlaceholders);
154
155            $this->assertEquals($countEnglish, $countForeign,
156                join("\n",
157                    [
158                        "$file: unequal amount of sprintf format placeholders in '$name'",
159                        "en: '" . $english[$key] . "'",
160                        "$code: '$value'",
161                    ]
162                )
163            );
164
165            $this->assertEquals($englishPlaceholders, $foreignPlaceholders,
166                join("\n",
167                    [
168                        "$file: sprintf format mismatch in '$name'",
169                        "en: '" . $english[$key] . "'",
170                        "$code: '$value'",
171                    ]
172                )
173            );
174        }
175    }
176
177    /**
178     * Parses the placeholders from a string and brings them in the correct order
179     *
180     * This has its own test below.
181     *
182     * @param string $string
183     */
184    protected function parsePlaceholders($string)
185    {
186        if (!preg_match_all('/%(?:([0-9]+)\$)?([-.0-9hl]*?[%dufsc])/', $string, $matches, PREG_SET_ORDER)) {
187            return [];
188        }
189
190        // Given this string : 'schproutch %2$s with %1$04d in %-20s plouf'
191        // we have this in $matches:
192        // [
193        //     0 => ['%2$s', 2, 's'],
194        //     1 => ['%1$04d', 1, '04d'],
195        //     2 => ['%-20s', '', '-20s'],
196        // ]
197
198        // sort by the given sorting in key 1
199        usort($matches, function ($a, $b) {
200            if ($a[1] === $b[1]) return 0; // keep as is
201
202            // sort empties towards the back
203            if ($a[1] === '') $a[1] = 9999;
204            if ($b[1] === '') $b[1] = 9999;
205
206            // compare sort numbers
207            if ((int)$a[1] < (int)$b[1]) return -1;
208            if ((int)$a[1] > (int)$b[1]) return 1;
209            return 0;
210        });
211
212        // return values in key 2
213        return array_column($matches, 2);
214    }
215
216    /**
217     * Dataprovider for the parsePlaceholder test
218     * @return array[]
219     */
220    public function providePlaceholders()
221    {
222        return [
223            ['schproutch %2$s with %1$04d in %-20s plouf', ['04d', 's', '-20s']],
224        ];
225    }
226
227    /**
228     * Test the parsePlaceholder utility function above
229     *
230     * @param string $input
231     * @param array $expected
232     * @dataProvider providePlaceholders
233     */
234    public function testParsePlaceholders($input, $expected)
235    {
236        $this->assertEquals($expected, $this->parsePlaceholders($input));
237    }
238}
239