Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.52% covered (warning)
78.52%
117 / 149
50.00% covered (danger)
50.00%
7 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ValidationRunner
78.52% covered (warning)
78.52%
117 / 149
50.00% covered (danger)
50.00%
7 / 14
91.32
0.00% covered (danger)
0.00%
0 / 1
 __construct
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 foldValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setValidators
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addValidator
60.71% covered (warning)
60.71%
17 / 28
0.00% covered (danger)
0.00%
0 / 1
6.52
 getValidators
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getInsertableValidators
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 validateMessage
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 quickValidate
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 reloadIgnorePatterns
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
8.11
 filterValidations
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 shouldIgnore
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 matchesIgnorePattern
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 doesKeyMatch
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
10.06
 runValidation
63.16% covered (warning)
63.16%
12 / 19
0.00% covered (danger)
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
12namespace MediaWiki\Extension\Translate\Validation;
13
14use Exception;
15use FormatJson;
16use InvalidArgumentException;
17use MediaWiki\Extension\Translate\MessageLoading\Message;
18use MediaWiki\Extension\Translate\Services;
19use MediaWiki\Extension\Translate\TranslatorInterface\Insertable\InsertablesSuggester;
20use MediaWiki\Extension\Translate\Utilities\PHPVariableLoader;
21use 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 */
39class 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: " . gettype( $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}