Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
78.52% |
117 / 149 |
|
50.00% |
7 / 14 |
CRAP | |
0.00% |
0 / 1 |
ValidationRunner | |
78.52% |
117 / 149 |
|
50.00% |
7 / 14 |
91.32 | |
0.00% |
0 / 1 |
__construct | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
foldValue | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setValidators | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
addValidator | |
60.71% |
17 / 28 |
|
0.00% |
0 / 1 |
6.52 | |||
getValidators | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getInsertableValidators | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
validateMessage | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
quickValidate | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
4 | |||
reloadIgnorePatterns | |
88.00% |
22 / 25 |
|
0.00% |
0 / 1 |
8.11 | |||
filterValidations | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
shouldIgnore | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
5 | |||
matchesIgnorePattern | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
doesKeyMatch | |
76.47% |
13 / 17 |
|
0.00% |
0 / 1 |
10.06 | |||
runValidation | |
63.16% |
12 / 19 |
|
0.00% |
0 / 1 |
13.05 |
1 | <?php |
2 | /** |
3 | * Message validation framework. |
4 | * |
5 | * @file |
6 | * @defgroup MessageValidator Message Validators |
7 | * @author Abijeet Patro |
8 | * @author Niklas Laxström |
9 | * @license GPL-2.0-or-later |
10 | */ |
11 | |
12 | namespace MediaWiki\Extension\Translate\Validation; |
13 | |
14 | use Exception; |
15 | use InvalidArgumentException; |
16 | use MediaWiki\Extension\Translate\MessageLoading\Message; |
17 | use MediaWiki\Extension\Translate\Services; |
18 | use MediaWiki\Extension\Translate\TranslatorInterface\Insertable\InsertablesSuggester; |
19 | use MediaWiki\Extension\Translate\Utilities\PHPVariableLoader; |
20 | use MediaWiki\Json\FormatJson; |
21 | use RuntimeException; |
22 | |
23 | /** |
24 | * Message validator is used to run validators to find common mistakes so that |
25 | * translators can fix them quickly. This is an improvement over the old Message |
26 | * Checker framework because it allows maintainers to enforce a validation so |
27 | * that translations that do not pass validation are not saved. |
28 | * |
29 | * To create your own validator, implement the MessageValidator interface. |
30 | * |
31 | * There are two types of notices - error and warning. |
32 | * |
33 | * @link https://www.mediawiki.org/wiki/Help:Extension:Translate/Group_configuration#VALIDATORS |
34 | * @link https://www.mediawiki.org/wiki/Help:Extension:Translate/Validators |
35 | * |
36 | * @ingroup MessageValidator |
37 | * @since 2019.06 |
38 | */ |
39 | class ValidationRunner { |
40 | /** @var array List of validator data */ |
41 | protected $validators = []; |
42 | /** @var string Message group id */ |
43 | protected $groupId; |
44 | /** @var string[][]|null */ |
45 | private static $ignorePatterns; |
46 | |
47 | public function __construct( string $groupId ) { |
48 | if ( self::$ignorePatterns === null ) { |
49 | // TODO: Review if this logic belongs in this class. |
50 | self::reloadIgnorePatterns(); |
51 | } |
52 | |
53 | $this->groupId = $groupId; |
54 | } |
55 | |
56 | /** Normalise validator keys. */ |
57 | protected static function foldValue( string $value ): string { |
58 | return str_replace( ' ', '_', strtolower( $value ) ); |
59 | } |
60 | |
61 | /** |
62 | * Set the validators for this group. |
63 | * |
64 | * Removes the existing validators. |
65 | * |
66 | * @param array $validatorConfigs List of Validator configurations |
67 | * @see addValidator() |
68 | */ |
69 | public function setValidators( array $validatorConfigs ): void { |
70 | $this->validators = []; |
71 | foreach ( $validatorConfigs as $config ) { |
72 | $this->addValidator( $config ); |
73 | } |
74 | } |
75 | |
76 | /** Add a validator for this group. */ |
77 | public function addValidator( array $validatorConfig ): void { |
78 | $validatorId = $validatorConfig['id'] ?? null; |
79 | $className = $validatorConfig['class'] ?? null; |
80 | |
81 | if ( $validatorId !== null ) { |
82 | $validator = ValidatorFactory::get( |
83 | $validatorId, |
84 | $validatorConfig['params'] ?? null |
85 | ); |
86 | } elseif ( $className !== null ) { |
87 | $validator = ValidatorFactory::loadInstance( |
88 | $className, |
89 | $validatorConfig['params'] ?? null |
90 | ); |
91 | } else { |
92 | throw new InvalidArgumentException( |
93 | 'Validator configuration does not specify the \'class\' or \'id\'.' |
94 | ); |
95 | } |
96 | |
97 | $isInsertable = $validatorConfig['insertable'] ?? false; |
98 | if ( $isInsertable && !$validator instanceof InsertablesSuggester ) { |
99 | $actualClassName = get_class( $validator ); |
100 | throw new InvalidArgumentException( |
101 | "Insertable validator $actualClassName does not implement InsertablesSuggester interface." |
102 | ); |
103 | } |
104 | |
105 | $this->validators[] = [ |
106 | 'instance' => $validator, |
107 | 'insertable' => $isInsertable, |
108 | 'enforce' => $validatorConfig['enforce'] ?? false, |
109 | 'include' => $validatorConfig['keymatch'] ?? $validatorConfig['include'] ?? false, |
110 | 'exclude' => $validatorConfig['exclude'] ?? false |
111 | ]; |
112 | } |
113 | |
114 | /** |
115 | * Return the currently set validators for this group. |
116 | * |
117 | * @return MessageValidator[] List of validators |
118 | */ |
119 | public function getValidators(): array { |
120 | return array_column( $this->validators, 'instance' ); |
121 | } |
122 | |
123 | /** |
124 | * Return currently set validators that are insertable. |
125 | * |
126 | * @return MessageValidator[] List of insertable |
127 | * validators |
128 | */ |
129 | public function getInsertableValidators(): array { |
130 | $insertableValidators = []; |
131 | foreach ( $this->validators as $validator ) { |
132 | if ( $validator['insertable'] === true ) { |
133 | $insertableValidators[] = $validator['instance']; |
134 | } |
135 | } |
136 | |
137 | return $insertableValidators; |
138 | } |
139 | |
140 | /** |
141 | * Validate a translation of a message. |
142 | * |
143 | * Returns a ValidationResult that contains methods to print the issues. |
144 | */ |
145 | public function validateMessage( |
146 | Message $message, |
147 | string $code, |
148 | bool $ignoreWarnings = false |
149 | ): ValidationResult { |
150 | $errors = new ValidationIssues(); |
151 | $warnings = new ValidationIssues(); |
152 | |
153 | foreach ( $this->validators as $validator ) { |
154 | $this->runValidation( |
155 | $validator, |
156 | $message, |
157 | $code, |
158 | $errors, |
159 | $warnings, |
160 | $ignoreWarnings |
161 | ); |
162 | } |
163 | |
164 | $errors = $this->filterValidations( $message->key(), $errors, $code ); |
165 | $warnings = $this->filterValidations( $message->key(), $warnings, $code ); |
166 | |
167 | return new ValidationResult( $errors, $warnings ); |
168 | } |
169 | |
170 | /** Validate a message, and return as soon as any validation fails. */ |
171 | public function quickValidate( |
172 | Message $message, |
173 | string $code, |
174 | bool $ignoreWarnings = false |
175 | ): ValidationResult { |
176 | $errors = new ValidationIssues(); |
177 | $warnings = new ValidationIssues(); |
178 | |
179 | foreach ( $this->validators as $validator ) { |
180 | $this->runValidation( |
181 | $validator, |
182 | $message, |
183 | $code, |
184 | $errors, |
185 | $warnings, |
186 | $ignoreWarnings |
187 | ); |
188 | |
189 | $errors = $this->filterValidations( $message->key(), $errors, $code ); |
190 | $warnings = $this->filterValidations( $message->key(), $warnings, $code ); |
191 | |
192 | if ( $warnings->hasIssues() || $errors->hasIssues() ) { |
193 | break; |
194 | } |
195 | } |
196 | |
197 | return new ValidationResult( $errors, $warnings ); |
198 | } |
199 | |
200 | /** @internal Should only be used by tests and inside this class. */ |
201 | public static function reloadIgnorePatterns(): void { |
202 | $validationExclusionFile = Services::getInstance()->getConfigHelper()->getValidationExclusionFile(); |
203 | |
204 | if ( $validationExclusionFile === false ) { |
205 | self::$ignorePatterns = []; |
206 | return; |
207 | } |
208 | |
209 | $list = PHPVariableLoader::loadVariableFromPHPFile( |
210 | $validationExclusionFile, |
211 | 'validationExclusionList' |
212 | ); |
213 | $keys = [ 'group', 'check', 'subcheck', 'code', 'message' ]; |
214 | |
215 | if ( $list && !is_array( $list ) ) { |
216 | throw new InvalidArgumentException( |
217 | "validationExclusionList defined in $validationExclusionFile must be an array" |
218 | ); |
219 | } |
220 | |
221 | foreach ( $list as $key => $pattern ) { |
222 | foreach ( $keys as $checkKey ) { |
223 | if ( !isset( $pattern[$checkKey] ) ) { |
224 | $list[$key][$checkKey] = '#'; |
225 | } elseif ( is_array( $pattern[$checkKey] ) ) { |
226 | $list[$key][$checkKey] = |
227 | array_map( |
228 | [ self::class, 'foldValue' ], |
229 | $pattern[$checkKey] |
230 | ); |
231 | } else { |
232 | $list[$key][$checkKey] = self::foldValue( $pattern[$checkKey] ); |
233 | } |
234 | } |
235 | } |
236 | |
237 | self::$ignorePatterns = $list; |
238 | } |
239 | |
240 | /** Filter validations based on an ignore list. */ |
241 | private function filterValidations( |
242 | string $messageKey, |
243 | ValidationIssues $issues, |
244 | string $targetLanguage |
245 | ): ValidationIssues { |
246 | $filteredIssues = new ValidationIssues(); |
247 | |
248 | foreach ( $issues as $issue ) { |
249 | foreach ( self::$ignorePatterns as $pattern ) { |
250 | if ( $this->shouldIgnore( $messageKey, $issue, $this->groupId, $targetLanguage, $pattern ) ) { |
251 | continue 2; |
252 | } |
253 | } |
254 | $filteredIssues->add( $issue ); |
255 | } |
256 | |
257 | return $filteredIssues; |
258 | } |
259 | |
260 | private function shouldIgnore( |
261 | string $messageKey, |
262 | ValidationIssue $issue, |
263 | string $messageGroupId, |
264 | string $targetLanguage, |
265 | array $pattern |
266 | ): bool { |
267 | return $this->matchesIgnorePattern( $pattern['group'], $messageGroupId ) |
268 | && $this->matchesIgnorePattern( $pattern['check'], $issue->type() ) |
269 | && $this->matchesIgnorePattern( $pattern['subcheck'], $issue->subType() ) |
270 | && $this->matchesIgnorePattern( $pattern['message'], $messageKey ) |
271 | && $this->matchesIgnorePattern( $pattern['code'], $targetLanguage ); |
272 | } |
273 | |
274 | /** |
275 | * Match validation information against an ignore pattern. |
276 | * |
277 | * @param string|string[] $pattern |
278 | * @param string $value The actual value in the validation produced by the validator |
279 | * @return bool True if the pattern matches the value. |
280 | */ |
281 | private function matchesIgnorePattern( $pattern, string $value ): bool { |
282 | if ( $pattern === '#' ) { |
283 | return true; |
284 | } elseif ( is_array( $pattern ) ) { |
285 | return in_array( strtolower( $value ), $pattern, true ); |
286 | } else { |
287 | return strtolower( $value ) === $pattern; |
288 | } |
289 | } |
290 | |
291 | /** |
292 | * Check if key matches validator's key patterns. |
293 | * Only relevant if the 'include' or 'exclude' option is specified in the validator. |
294 | * |
295 | * @param string $key |
296 | * @param string[] $keyMatches |
297 | * @return bool True if the key matches one of the matchers, false otherwise. |
298 | */ |
299 | protected function doesKeyMatch( string $key, array $keyMatches ): bool { |
300 | $normalizedKey = lcfirst( $key ); |
301 | foreach ( $keyMatches as $match ) { |
302 | if ( is_string( $match ) ) { |
303 | if ( lcfirst( $match ) === $normalizedKey ) { |
304 | return true; |
305 | } |
306 | continue; |
307 | } |
308 | |
309 | // The value is neither a string nor an array, should never happen but still handle it. |
310 | if ( !is_array( $match ) ) { |
311 | throw new InvalidArgumentException( |
312 | "Invalid key matcher configuration passed. Expected type: array or string. " . |
313 | "Received: " . get_debug_type( $match ) . ". match value: " . FormatJson::encode( $match ) |
314 | ); |
315 | } |
316 | |
317 | $matcherType = $match['type']; |
318 | $pattern = $match['pattern']; |
319 | |
320 | // If regex matches, or wildcard matches return true, else continue processing. |
321 | if ( |
322 | ( $matcherType === 'regex' && preg_match( $pattern, $normalizedKey ) === 1 ) || |
323 | ( $matcherType === 'wildcard' && fnmatch( $pattern, $normalizedKey ) ) |
324 | ) { |
325 | return true; |
326 | } |
327 | } |
328 | |
329 | return false; |
330 | } |
331 | |
332 | /** |
333 | * Run the validator to produce warnings and errors. |
334 | * |
335 | * May also skip validation depending on validator configuration and $ignoreWarnings. |
336 | */ |
337 | private function runValidation( |
338 | array $validatorData, |
339 | Message $message, |
340 | string $targetLanguage, |
341 | ValidationIssues $errors, |
342 | ValidationIssues $warnings, |
343 | bool $ignoreWarnings |
344 | ): void { |
345 | // Check if key match has been specified, and then check if the key matches it. |
346 | /** @var MessageValidator $validator */ |
347 | $validator = $validatorData['instance']; |
348 | |
349 | $definition = $message->definition(); |
350 | if ( $definition === null ) { |
351 | // This should NOT happen, but add a check since it seems to be happening |
352 | // See: https://phabricator.wikimedia.org/T255669 |
353 | return; |
354 | } |
355 | |
356 | try { |
357 | $includedKeys = $validatorData['include']; |
358 | if ( $includedKeys !== false && !$this->doesKeyMatch( $message->key(), $includedKeys ) ) { |
359 | return; |
360 | } |
361 | |
362 | $excludedKeys = $validatorData['exclude']; |
363 | if ( $excludedKeys !== false && $this->doesKeyMatch( $message->key(), $excludedKeys ) ) { |
364 | return; |
365 | } |
366 | |
367 | if ( $validatorData['enforce'] === true ) { |
368 | $errors->merge( $validator->getIssues( $message, $targetLanguage ) ); |
369 | } elseif ( !$ignoreWarnings ) { |
370 | $warnings->merge( $validator->getIssues( $message, $targetLanguage ) ); |
371 | } |
372 | // else: caller does not want warnings, skip running the validator |
373 | } catch ( Exception $e ) { |
374 | throw new RuntimeException( |
375 | 'An error occurred while validating message: ' . $message->key() . '; group: ' . |
376 | $this->groupId . "; validator: " . get_class( $validator ) . "\n. Exception: $e" |
377 | ); |
378 | } |
379 | } |
380 | } |