@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3final class PhorgeInternationalizationValidator extends Phobject {
4 private function validateTranslation($locale, $proto, $transl, $types, $n) {
5 $errors = array();
6 if ($n >= count($types)) {
7 if (is_array($transl)) {
8 $errors[] = pht(
9 'The locale `%s` defines a translation for the key `%s`, '.
10 'which has at least %s level(s) of arrays, '.
11 'however the source message has only %s parameter(s).',
12 $locale,
13 $proto,
14 new PhutilNumber($n + 1),
15 phutil_count($types));
16 }
17 $data = array();
18 foreach ($types as $type) {
19 if ($type === 'phutilnumber') {
20 // Make a class that can be converted into a string
21 // (to mimic the conversion pht() will do)
22 // but not to a number (the double-conversion loses data)
23 // See T16454
24 $data[] = new PhorgeStringablePlaceholder();
25 } else if ($type === 'number') {
26 // no good way to check numbers being converted to strings
27 // without parsing the format specifier ourself
28 // (remember xsprintf can't work with translated strings since
29 // they can use backreferences, format specifiers, etc)
30 $data[] = 3;
31 } else if ($type === null) {
32 // This could either be a string or a number, let PHP type
33 // conversions handle it
34 $data[] = 'abc';
35 } else {
36 throw new Exception(pht('Bogus type "%s" for "%s"', $type, $proto));
37 }
38 }
39 try {
40 $parsed = vsprintf($transl, $data);
41 } catch (ValueError $ex) {
42 // In PHP 8 vsprintf throws a ValueError for bad data;
43 // in PHP7 it returns false
44 $parsed = false;
45 } catch (RuntimeException $ex) {
46 // The types of the args don't match (the RuntimeException comes
47 // from PhutilErrorHandler.php throwing what was originally a PHP
48 // warning)
49 $msg = $ex->getMessage();
50 if ($msg === 'Object of class PhorgeStringablePlaceholder '.
51 'could not be converted to int') {
52 $errors[] = pht(
53 'The locale `%s` defines a translation for the key `%s` which '.
54 'uses %%d to represent a PhutilNumber. This loses data if the '.
55 'number ends up being formatted with thousands specifiers. '.
56 'See T16454',
57 $locale,
58 $proto);
59 return $errors;
60 }
61 // This shouldn't happen, but if something else goes wrong fall
62 // through to the generic `failed to interpolate properly` error
63 $parsed = false;
64 }
65 if ($parsed === false) {
66 $errors[] = pht(
67 'The locale `%s` defines a translation for the key `%s` which '.
68 'failed to interpolate properly. Probably it defines too many '.
69 'parameters.',
70 $locale,
71 $proto);
72 }
73 return $errors;
74 }
75 if (!is_array($transl)) {
76 $transl = array($transl);
77 }
78 $type = $types[$n];
79 if ($type === null && count($transl) != 1) {
80 $errors[] = pht(
81 'The locale `%s` defines a translation for the key `%s`, which varies '.
82 'on the plurality or gender of parameter %d, however that parameter '.
83 'is not a number or person.',
84 $locale,
85 $proto,
86 $n + 1);
87 }
88 foreach ($transl as $subarray) {
89 $errors = array_merge(
90 $errors,
91 $this->validateTranslation($locale, $proto, $subarray, $types, $n + 1));
92 }
93 return $errors;
94 }
95 public function validateLibraries($loaded_json) {
96 $errors = array();
97 $all_translations = PhutilTranslation::getAllTranslations();
98 $locales = PhutilLocale::loadAllLocales();
99 $keyed_translations = array();
100 $override_key = 'translation.override';
101 try {
102 $trans_override = PhabricatorEnv::getEnvConfig($override_key);
103 $all_translations[$override_key] = $trans_override;
104 } catch (Throwable $ex) {
105 // If Phorge config is hosed then just don't check translation.override
106 }
107 foreach ($all_translations as $locale_code => $translations) {
108 if (!isset($locales[$locale_code]) && $locale_code != $override_key) {
109 $errors[] = pht(
110 'Translations are defined for the locale `%s`, '.
111 'which is not recognized.',
112 $locale_code);
113 }
114 foreach ($translations as $proto => $transl) {
115 if (!isset($keyed_translations[$proto])) {
116 $keyed_translations[$proto] = array();
117 }
118 $keyed_translations[$proto][$locale_code] = $transl;
119 // Check for unused translations
120 if (!isset($loaded_json[$proto])) {
121 $errors[] = pht(
122 'The locale `%s` defines a translation for the string "%s", '.
123 'however that string does not appear to be referenced '.
124 'by the codebase.',
125 $locale_code,
126 $proto);
127 }
128 }
129 }
130 foreach ($loaded_json as $string => $spec) {
131 // Check for wrong branches by parameter type
132 $translations = idx($keyed_translations, $string, array());
133 foreach ($translations as $locale => $translation) {
134 $errors = array_merge($errors, $this->validateTranslation(
135 $locale,
136 $string,
137 $translation,
138 $spec['types'],
139 0));
140 }
141 // Run it on the proto-English as a translation too
142 // since this also does some parameter type checking
143 // (some of which may belong better in a linter than here)
144 $errors = array_merge($errors, $this->validateTranslation(
145 id(new PhutilRawEnglishLocale())->getLocaleCode(),
146 $string,
147 $string,
148 $spec['types'],
149 0));
150 // Check for missing branches in US english
151 if (str_contains($string, '(s)')) {
152 if (!isset($keyed_translations[$string]['en_US'])) {
153 foreach ($spec['types'] as $type) {
154 if ($type === 'number') {
155 $errors[] = pht(
156 'The string "%s" contains the placeholder "(s)" and '.
157 'a numeric parameter on which to vary the (s) by, '.
158 'however the builtin US English translation does not do so.',
159 $string);
160 }
161 }
162 }
163 }
164 }
165 return $errors;
166 }
167 public function loadExtractions($run_extractor, $quiet = false) {
168 $libraries = PhutilBootloader::getInstance()->getAllLibraries();
169 $phorge_root = phutil_get_library_root('phorge');
170 $i18n_bin = Filesystem::resolvePath('../bin/i18n', $phorge_root);
171 $all_json = array();
172 foreach ($libraries as $lib) {
173 $root = phutil_get_library_root($lib);
174 $json = Filesystem::resolvePath('.cache/i18n_strings.json', $root);
175 if ($run_extractor) {
176 // The command needs to be stated twice to avoid the linter complaining
177 // about the arg not being a scalar string
178 if ($quiet) {
179 execx(
180 '%R extract %s',
181 $i18n_bin,
182 $root);
183 } else {
184 $err = phutil_passthru(
185 '%R extract %s',
186 $i18n_bin,
187 $root);
188 if ($err) {
189 throw new Exception(pht(
190 'Failed to run i18n extractor: %s',
191 $err));
192 }
193 }
194 } else if (!Filesystem::pathExists($json)) {
195 throw new Exception(pht(
196 'Strings have not yet been extracted for library %s. '.
197 'Run `%s` for that library first to extract them or '.
198 're-run with `%s` to automatically extract missing strngs.',
199 $lib,
200 'bin/i18n extract',
201 '--extract'));
202 }
203 $all_json += phutil_json_decode(Filesystem::readFile($json));
204 }
205 // Add extra date elements from PhutilTranslator::translateDate
206 // which aren't in a static pht() call
207 $date = array('types' => array());
208 $all_json['Jan'] = $date;
209 $all_json['Feb'] = $date;
210 $all_json['Mar'] = $date;
211 $all_json['Apr'] = $date;
212 $all_json['Jun'] = $date;
213 $all_json['Jul'] = $date;
214 $all_json['Aug'] = $date;
215 $all_json['Sep'] = $date;
216 $all_json['Oct'] = $date;
217 $all_json['Nov'] = $date;
218 $all_json['Dec'] = $date;
219 return $all_json;
220 }
221}