summaryrefslogtreecommitdiff
blob: e37113210e0f21394848c0e0c7597348d5400d0f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
<?php
/**
 * Message validation framework.
 *
 * @file
 * @defgroup MessageValidator Message Validators
 * @author Abijeet Patro
 * @author Niklas Laxström
 * @license GPL-2.0-or-later
 */

use MediaWiki\Extensions\Translate\MessageValidator\ValidationResult;
use MediaWiki\Extensions\Translate\MessageValidator\ValidatorFactory;
use MediaWiki\Extensions\Translate\Validation\MessageValidator as MessageValidatorInterface;
use MediaWiki\Extensions\Translate\Validation\ValidationIssue;
use MediaWiki\Extensions\Translate\Validation\ValidationIssues;

/**
 * Message validator is used to run validators to find common mistakes so that
 * translators can fix them quickly. This is an improvement over the old Message
 * Checker framework because it allows maintainers to enforce a validation so
 * that translations that do not pass validation are not saved.
 *
 * To create your own validator, implement the following interface,
 * @see MediaWiki\Extensions\Translate\Validation\MessageValidator
 *
 * In addition you can use the following Trait to reuse some pre-existing methods,
 * @see MediaWiki\Extensions\Translate\MessageValidator\ValidatorHelper
 *
 * There are two types of notices - error and warning.
 *
 * @link https://www.mediawiki.org/wiki/Help:Extension:Translate/Group_configuration#VALIDATORS
 * @link https://www.mediawiki.org/wiki/Help:Extension:Translate/Validators
 *
 * @ingroup MessageValidator
 * @since 2019.06
 */
class MessageValidator {
	/** @var array List of validator data */
	protected $validators = [];

	/** @var string Message group id */
	protected $groupId;

	/** @var string[][] */
	private static $ignorePatterns;

	public function __construct( string $groupId ) {
		if ( self::$ignorePatterns === null ) {
			// TODO: Review if this logic belongs in this class.
			self::reloadIgnorePatterns();
		}

		$this->groupId = $groupId;
	}

	/** Normalise validator keys. */
	protected static function foldValue( string $value ): string {
		return str_replace( ' ', '_', strtolower( $value ) );
	}

	/**
	 * Set the validators for this group.
	 *
	 * Removes the existing validators.
	 *
	 * @see addValidator()
	 * @param array $validatorConfigs List of Validator configurations
	 */
	public function setValidators( array $validatorConfigs ): void {
		$this->validators = [];
		foreach ( $validatorConfigs as $config ) {
			$this->addValidator( $config );
		}
	}

	/** Add a validator for this group. */
	public function addValidator( array $validatorConfig ): void {
		$validatorId = $validatorConfig['id'] ?? null;
		$className = $validatorConfig['class'] ?? null;

		if ( $validatorId !== null ) {
			$validator = ValidatorFactory::get(
				$validatorId,
				$validatorConfig['params'] ?? null
			);
		} elseif ( $className !== null ) {
			$validator = ValidatorFactory::loadInstance( $className,
				$validatorConfig['params'] ?? null );
		} else {
			throw new InvalidArgumentException(
				'Validator configuration does not specify the \'class\' or \'id\'.'
			);
		}

		$isInsertable = $validatorConfig['insertable'] ?? false;
		if ( $isInsertable && !$validator instanceof InsertablesSuggester ) {
			throw new InvalidArgumentException(
				"Insertable validator does not implement InsertablesSuggester interface."
			);
		}

		$this->validators[] = [
			'instance' => $validator,
			'insertable' => $isInsertable,
			'enforce' => $validatorConfig['enforce'] ?? false,
			'keymatch' => $validatorConfig['keymatch'] ?? false
		];
	}

	/**
	 * Return the currently set validators for this group.
	 *
	 * @return MessageValidatorInterface[] List of validators
	 */
	public function getValidators(): array {
		return array_map( function ( $validator ) {
			return $validator['instance'];
		}, $this->validators );
	}

	/**
	 * Return currently set validators that are insertable.
	 *
	 * @return MessageValidatorInterface[] List of insertable
	 * validators
	 */
	public function getInsertableValidators(): array {
		$insertableValidators = [];
		foreach ( $this->validators as $validator ) {
			if ( $validator['insertable'] === true ) {
				$insertableValidators[] = $validator['instance'];
			}
		}

		return $insertableValidators;
	}

	/**
	 * Validate a translation of a message.
	 *
	 * Returns a ValidationResult that contains methods to print the issues.
	 */
	public function validateMessage(
		TMessage $message, string $code, bool $ignoreWarnings = false
	): ValidationResult {
		$errors = new ValidationIssues();
		$warnings = new ValidationIssues();

		foreach ( $this->validators as $validator ) {
			$this->runValidation( $validator, $message, $code, $errors, $warnings, $ignoreWarnings );
		}

		$errors = $this->filterValidations( $errors, $code );
		$warnings = $this->filterValidations( $warnings, $code );

		return new ValidationResult( $errors, $warnings );
	}

	/** Validate a message, and return as soon as any validation fails. */
	public function quickValidate(
		TMessage $message, string $code, bool $ignoreWarnings = false
	): ValidationResult {
		$errors = new ValidationIssues();
		$warnings = new ValidationIssues();

		foreach ( $this->validators as $validator ) {
			$this->runValidation( $validator, $message, $code, $errors, $warnings, $ignoreWarnings );

			$errors = $this->filterValidations( $errors, $code );
			$warnings = $this->filterValidations( $warnings, $code );

			if ( $warnings->hasIssues() || $errors->hasIssues() ) {
				break;
			}
		}

		return new ValidationResult( $errors, $warnings );
	}

	/** @internal Should only be used by tests and inside this class. */
	public static function reloadIgnorePatterns(): void {
		global $wgTranslateCheckBlacklist;

		if ( $wgTranslateCheckBlacklist === false ) {
			self::$ignorePatterns = [];
			return;
		}

		$list = PHPVariableLoader::loadVariableFromPHPFile(
			$wgTranslateCheckBlacklist, 'checkBlacklist'
		);
		$keys = [ 'group', 'check', 'subcheck', 'code', 'message' ];

		foreach ( $list as $key => $pattern ) {
			foreach ( $keys as $checkKey ) {
				if ( !isset( $pattern[$checkKey] ) ) {
					$list[$key][$checkKey] = '#';
				} elseif ( is_array( $pattern[$checkKey] ) ) {
					$list[$key][$checkKey] =
						array_map( 'MessageValidator::foldValue', $pattern[$checkKey] );
				} else {
					$list[$key][$checkKey] = self::foldValue( $pattern[$checkKey] );
				}
			}
		}

		self::$ignorePatterns = $list;
	}

	/** Filter validations based on a ignore list. */
	private function filterValidations(
		ValidationIssues $issues,
		string $targetLanguage
	): ValidationIssues {
		$filteredIssues = new ValidationIssues();

		foreach ( $issues as $issue ) {
			foreach ( self::$ignorePatterns as $pattern ) {
				if ( $this->shouldIgnore( $issue, $this->groupId, $targetLanguage, $pattern ) ) {
					continue 2;
				}
			}
			$filteredIssues->add( $issue );
		}

		return $filteredIssues;
	}

	private function shouldIgnore(
		ValidationIssue $issue,
		string $messageGroupId,
		string $targetLanguage,
		array $pattern
	): bool {
		return $this->match( $pattern['group'], $messageGroupId )
			&& $this->match( $pattern['check'], $issue->type() )
			&& $this->match( $pattern['subcheck'], $issue->subType() )
			&& $this->match( $pattern['message'], $issue->messageKey() )
			&& $this->match( $pattern['code'], $targetLanguage );
	}

	/**
	 * Match validation information against a ignore pattern.
	 *
	 * @param string|array $pattern
	 * @param string $value The actual value in the validation produced by the validator
	 * @return bool True if the pattern matches the value.
	 */
	protected function match( $pattern, string $value ): bool {
		if ( $pattern === '#' ) {
			return true;
		} elseif ( is_array( $pattern ) ) {
			return in_array( strtolower( $value ), $pattern, true );
		} else {
			return strtolower( $value ) === $pattern;
		}
	}

	/**
	 * Check if key matches validator's key patterns.
	 *
	 * Only relevant if the 'keymatch' option is specified in the validator.
	 *
	 * @param string $key
	 * @param string[] $keyMatches
	 * @return bool True if the key matches one of the matchers, false otherwise.
	 */
	protected function doesKeyMatch( string $key, array $keyMatches ): bool {
		$normalizedKey = lcfirst( $key );
		foreach ( $keyMatches as $match ) {
			if ( is_string( $match ) ) {
				if ( lcfirst( $match ) === $normalizedKey ) {
					return true;
				}
				continue;
			}

			// The value is neither a string nor an array, should never happen but still handle it.
			if ( !is_array( $match ) ) {
				throw new InvalidArgumentException(
					"Invalid key matcher configuration passed. Expected type: array or string. " .
					"Recieved: " . gettype( $match ) . ". match value: " . FormatJson::encode( $match )
				);
			}

			$matcherType = $match['type'];
			$pattern = $match['pattern'];

			// If regex matches, or wildcard matches return true, else continue processing.
			if (
				( $matcherType === 'regex' && preg_match( $pattern, $normalizedKey ) === 1 ) ||
				( $matcherType === 'wildcard' && fnmatch( $pattern, $normalizedKey ) )
			) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Run the validator to produce warnings and errors.
	 *
	 * May also skip validation depending on validator configuration and $ignoreWarnings.
	 */
	private function runValidation(
		array $validatorData,
		TMessage $message,
		string $targetLanguage,
		ValidationIssues $errors,
		ValidationIssues $warnings,
		bool $ignoreWarnings
	): void {
		// Check if key match has been specified, and then check if the key matches it.
		/** @var MessageValidatorInterface $validator */
		$validator = $validatorData['instance'];

		$definition = $message->definition();
		if ( $definition === null ) {
			// This should NOT happen, but add a check since it seems to be happening
			// See: https://phabricator.wikimedia.org/T255669
			return;
		}

		try {
			$keyMatches = $validatorData['keymatch'];
			if ( $keyMatches !== false && !$this->doesKeyMatch( $message->key(), $keyMatches ) ) {
				return;
			}

			if ( $validatorData['enforce'] === true ) {
				$errors->merge( $validator->getIssues( $message, $targetLanguage ) );
			} elseif ( !$ignoreWarnings ) {
				$warnings->merge( $validator->getIssues( $message, $targetLanguage ) );
			}
			// else: caller does not want warnings, skip running the validator
		} catch ( Exception $e ) {
			throw new \RuntimeException(
				'An error occurred while validating message: ' . $message->key() . '; group: ' .
				$this->groupId . "; validator: " . get_class( $validator ) . "\n. Exception: $e"
			);
		}
	}
}