summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'MLEB/Translate/src/Validation')
-rw-r--r--MLEB/Translate/src/Validation/LegacyValidatorAdapter.php9
-rw-r--r--MLEB/Translate/src/Validation/MessageValidator.php4
-rw-r--r--MLEB/Translate/src/Validation/ValidationIssue.php4
-rw-r--r--MLEB/Translate/src/Validation/ValidationIssues.php4
-rw-r--r--MLEB/Translate/src/Validation/ValidationResult.php104
-rw-r--r--MLEB/Translate/src/Validation/ValidationRunner.php375
-rw-r--r--MLEB/Translate/src/Validation/Validator.php23
-rw-r--r--MLEB/Translate/src/Validation/ValidatorFactory.php118
-rw-r--r--MLEB/Translate/src/Validation/Validators/BraceBalanceValidator.php57
-rw-r--r--MLEB/Translate/src/Validation/Validators/EscapeCharacterValidator.php91
-rw-r--r--MLEB/Translate/src/Validation/Validators/GettextNewlineValidator.php49
-rw-r--r--MLEB/Translate/src/Validation/Validators/GettextPluralValidator.php108
-rw-r--r--MLEB/Translate/src/Validation/Validators/InsertableRegexValidator.php80
-rw-r--r--MLEB/Translate/src/Validation/Validators/InsertableRubyVariableValidator.php21
-rw-r--r--MLEB/Translate/src/Validation/Validators/IosVariableValidator.php23
-rw-r--r--MLEB/Translate/src/Validation/Validators/MatchSetValidator.php66
-rw-r--r--MLEB/Translate/src/Validation/Validators/MediaWikiLinkValidator.php74
-rw-r--r--MLEB/Translate/src/Validation/Validators/MediaWikiPageNameValidator.php41
-rw-r--r--MLEB/Translate/src/Validation/Validators/MediaWikiParameterValidator.php17
-rw-r--r--MLEB/Translate/src/Validation/Validators/MediaWikiPluralValidator.php147
-rw-r--r--MLEB/Translate/src/Validation/Validators/MediaWikiTimeListValidator.php84
-rw-r--r--MLEB/Translate/src/Validation/Validators/NewlineValidator.php102
-rw-r--r--MLEB/Translate/src/Validation/Validators/NumericalParameterValidator.php17
-rw-r--r--MLEB/Translate/src/Validation/Validators/PrintfValidator.php18
-rw-r--r--MLEB/Translate/src/Validation/Validators/PythonInterpolationValidator.php18
-rw-r--r--MLEB/Translate/src/Validation/Validators/ReplacementValidator.php54
-rw-r--r--MLEB/Translate/src/Validation/Validators/SmartFormatPluralValidator.php112
-rw-r--r--MLEB/Translate/src/Validation/Validators/UnicodePluralValidator.php112
28 files changed, 1925 insertions, 7 deletions
diff --git a/MLEB/Translate/src/Validation/LegacyValidatorAdapter.php b/MLEB/Translate/src/Validation/LegacyValidatorAdapter.php
index a63699d5..1b194334 100644
--- a/MLEB/Translate/src/Validation/LegacyValidatorAdapter.php
+++ b/MLEB/Translate/src/Validation/LegacyValidatorAdapter.php
@@ -7,10 +7,9 @@
declare( strict_types = 1 );
-namespace MediaWiki\Extensions\Translate\Validation;
+namespace MediaWiki\Extension\Translate\Validation;
-use InsertablesSuggester;
-use MediaWiki\Extensions\Translate\MessageValidator\Validator;
+use MediaWiki\Extension\Translate\TranslatorInterface\Insertable\InsertablesSuggester;
use TMessage;
/**
@@ -52,7 +51,7 @@ class LegacyValidatorAdapter implements MessageValidator, InsertablesSuggester {
}
/** @inheritDoc */
- public function getInsertables( $text ) {
+ public function getInsertables( string $text ): array {
if ( $this->validator instanceof InsertablesSuggester ) {
return $this->validator->getInsertables( $text );
}
@@ -60,3 +59,5 @@ class LegacyValidatorAdapter implements MessageValidator, InsertablesSuggester {
return [];
}
}
+
+class_alias( LegacyValidatorAdapter::class, '\MediaWiki\Extensions\Translate\LegacyValidatorAdapter' );
diff --git a/MLEB/Translate/src/Validation/MessageValidator.php b/MLEB/Translate/src/Validation/MessageValidator.php
index a488a021..4946d0a9 100644
--- a/MLEB/Translate/src/Validation/MessageValidator.php
+++ b/MLEB/Translate/src/Validation/MessageValidator.php
@@ -7,7 +7,7 @@
declare( strict_types = 1 );
-namespace MediaWiki\Extensions\Translate\Validation;
+namespace MediaWiki\Extension\Translate\Validation;
use TMessage;
@@ -22,3 +22,5 @@ use TMessage;
interface MessageValidator {
public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues;
}
+
+class_alias( MessageValidator::class, '\MediaWiki\Extensions\Translate\MessageValidator' );
diff --git a/MLEB/Translate/src/Validation/ValidationIssue.php b/MLEB/Translate/src/Validation/ValidationIssue.php
index f47d7000..822aeb1c 100644
--- a/MLEB/Translate/src/Validation/ValidationIssue.php
+++ b/MLEB/Translate/src/Validation/ValidationIssue.php
@@ -5,7 +5,7 @@
* @license GPL-2.0-or-later
*/
-namespace MediaWiki\Extensions\Translate\Validation;
+namespace MediaWiki\Extension\Translate\Validation;
/**
* Value object.
@@ -52,3 +52,5 @@ class ValidationIssue {
return $this->messageParams;
}
}
+
+class_alias( ValidationIssue::class, '\MediaWiki\Extensions\Translate\ValidationIssue' );
diff --git a/MLEB/Translate/src/Validation/ValidationIssues.php b/MLEB/Translate/src/Validation/ValidationIssues.php
index 5ca17cc4..f9278995 100644
--- a/MLEB/Translate/src/Validation/ValidationIssues.php
+++ b/MLEB/Translate/src/Validation/ValidationIssues.php
@@ -5,7 +5,7 @@
* @license GPL-2.0-or-later
*/
-namespace MediaWiki\Extensions\Translate\Validation;
+namespace MediaWiki\Extension\Translate\Validation;
use ArrayIterator;
use Countable;
@@ -51,3 +51,5 @@ class ValidationIssues implements Countable, IteratorAggregate {
return count( $this->issues );
}
}
+
+class_alias( ValidationIssues::class, '\MediaWiki\Extensions\Translate\ValidationIssues' );
diff --git a/MLEB/Translate/src/Validation/ValidationResult.php b/MLEB/Translate/src/Validation/ValidationResult.php
new file mode 100644
index 00000000..2b5a62d7
--- /dev/null
+++ b/MLEB/Translate/src/Validation/ValidationResult.php
@@ -0,0 +1,104 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation;
+
+use IContextSource;
+use InvalidArgumentException;
+
+/**
+ * Container for validation issues returned by MessageValidator.
+ *
+ * @author Abijeet Patro
+ * @author Niklas Laxström
+ * @license GPL-2.0-or-later
+ * @since 2020.06 (originally 2019.06)
+ */
+class ValidationResult {
+ /** @var ValidationIssues */
+ protected $errors;
+ /** @var ValidationIssues */
+ protected $warnings;
+
+ public function __construct( ValidationIssues $errors, ValidationIssues $warnings ) {
+ $this->errors = $errors;
+ $this->warnings = $warnings;
+ }
+
+ public function hasIssues(): bool {
+ return $this->hasWarnings() || $this->hasErrors();
+ }
+
+ public function getIssues(): ValidationIssues {
+ $issues = new ValidationIssues();
+ $issues->merge( $this->errors );
+ $issues->merge( $this->warnings );
+ return $issues;
+ }
+
+ public function hasWarnings(): bool {
+ return $this->warnings->hasIssues();
+ }
+
+ public function hasErrors(): bool {
+ return $this->errors->hasIssues();
+ }
+
+ public function getWarnings(): ValidationIssues {
+ return $this->warnings;
+ }
+
+ public function getErrors(): ValidationIssues {
+ return $this->errors;
+ }
+
+ public function getDescriptiveWarnings( IContextSource $context ): array {
+ return $this->expandMessages( $context, $this->warnings );
+ }
+
+ public function getDescriptiveErrors( IContextSource $context ): array {
+ return $this->expandMessages( $context, $this->errors );
+ }
+
+ private function expandMessages( IContextSource $context, ValidationIssues $issues ): array {
+ $expandMessage = function ( ValidationIssue $issue ) use ( $context ): string {
+ $params = $this->fixMessageParams( $context, $issue->messageParams() );
+ return $context->msg( $issue->messageKey() )->params( $params )->parse();
+ };
+
+ return array_map( $expandMessage, iterator_to_array( $issues ) );
+ }
+
+ private function fixMessageParams( IContextSource $context, array $params ): array {
+ $out = [];
+ $lang = $context->getLanguage();
+
+ foreach ( $params as $param ) {
+ if ( !is_array( $param ) ) {
+ $out[] = $param;
+ } else {
+ [ $type, $value ] = $param;
+ if ( $type === 'COUNT' ) {
+ $out[] = $lang->formatNum( $value );
+ } elseif ( $type === 'PARAMS' ) {
+ $out[] = $lang->commaList( $value );
+ } elseif ( $type === 'PLAIN-PARAMS' ) {
+ $value = array_map( 'wfEscapeWikiText', $value );
+ $out[] = $lang->commaList( $value );
+ } elseif ( $type === 'PLAIN' ) {
+ $out[] = wfEscapeWikiText( $value );
+ } elseif ( $type === 'MESSAGE' ) {
+ $messageKey = array_shift( $value );
+ $messageParams = $this->fixMessageParams( $context, $value );
+ $out[] = $context->msg( $messageKey )->params( $messageParams );
+ } else {
+ throw new InvalidArgumentException( "Unknown type $type" );
+ }
+ }
+ }
+
+ return $out;
+ }
+}
+
+class_alias( ValidationResult::class, '\MediaWiki\Extensions\Translate\ValidationResult' );
diff --git a/MLEB/Translate/src/Validation/ValidationRunner.php b/MLEB/Translate/src/Validation/ValidationRunner.php
new file mode 100644
index 00000000..b5b345bc
--- /dev/null
+++ b/MLEB/Translate/src/Validation/ValidationRunner.php
@@ -0,0 +1,375 @@
+<?php
+/**
+ * Message validation framework.
+ *
+ * @file
+ * @defgroup MessageValidator Message Validators
+ * @author Abijeet Patro
+ * @author Niklas Laxström
+ * @license GPL-2.0-or-later
+ */
+
+namespace MediaWiki\Extension\Translate\Validation;
+
+use Exception;
+use FormatJson;
+use InvalidArgumentException;
+use MediaWiki\Extension\Translate\TranslatorInterface\Insertable\InsertablesSuggester;
+use PHPVariableLoader;
+use RuntimeException;
+use TMessage;
+
+/**
+ * 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 MessageValidator interface.
+ *
+ * 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 ValidationRunner {
+ /** @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.
+ *
+ * @param array $validatorConfigs List of Validator configurations
+ * @see addValidator()
+ */
+ 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 ) {
+ $actualClassName = get_class( $validator );
+ throw new InvalidArgumentException(
+ "Insertable validator $actualClassName 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 MessageValidator[] 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 MessageValidator[] 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( $message->key(), $errors, $code );
+ $warnings = $this->filterValidations( $message->key(), $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( $message->key(), $errors, $code );
+ $warnings = $this->filterValidations( $message->key(), $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(
+ [ self::class, 'foldValue' ],
+ $pattern[$checkKey]
+ );
+ } else {
+ $list[$key][$checkKey] = self::foldValue( $pattern[$checkKey] );
+ }
+ }
+ }
+
+ self::$ignorePatterns = $list;
+ }
+
+ /** Filter validations based on a ignore list. */
+ private function filterValidations(
+ string $messageKey,
+ ValidationIssues $issues,
+ string $targetLanguage
+ ): ValidationIssues {
+ $filteredIssues = new ValidationIssues();
+
+ foreach ( $issues as $issue ) {
+ foreach ( self::$ignorePatterns as $pattern ) {
+ if ( $this->shouldIgnore( $messageKey, $issue, $this->groupId, $targetLanguage, $pattern ) ) {
+ continue 2;
+ }
+ }
+ $filteredIssues->add( $issue );
+ }
+
+ return $filteredIssues;
+ }
+
+ private function shouldIgnore(
+ string $messageKey,
+ ValidationIssue $issue,
+ string $messageGroupId,
+ string $targetLanguage,
+ array $pattern
+ ): bool {
+ return $this->matchesIgnorePattern( $pattern['group'], $messageGroupId )
+ && $this->matchesIgnorePattern( $pattern['check'], $issue->type() )
+ && $this->matchesIgnorePattern( $pattern['subcheck'], $issue->subType() )
+ && $this->matchesIgnorePattern( $pattern['message'], $messageKey )
+ && $this->matchesIgnorePattern( $pattern['code'], $targetLanguage );
+ }
+
+ /**
+ * Match validation information against a ignore pattern.
+ *
+ * @param string|string[] $pattern
+ * @param string $value The actual value in the validation produced by the validator
+ * @return bool True if the pattern matches the value.
+ */
+ private function matchesIgnorePattern( $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. " .
+ "Received: " . 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 MessageValidator $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"
+ );
+ }
+ }
+}
+
+class_alias( ValidationRunner::class, '\MediaWiki\Extensions\Translate\ValidationRunner' );
diff --git a/MLEB/Translate/src/Validation/Validator.php b/MLEB/Translate/src/Validation/Validator.php
new file mode 100644
index 00000000..c89f349b
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validator.php
@@ -0,0 +1,23 @@
+<?php
+/**
+ * Interface to be implemented by Validators.
+ *
+ * @file
+ * @author Abijeet Patro
+ * @license GPL-2.0-or-later
+ */
+
+namespace MediaWiki\Extension\Translate\Validation;
+
+use TMessage;
+
+/**
+ * Interface class built to be implement by validators
+ * @since 2019.06
+ * @deprecated since 2020.06
+ */
+interface Validator {
+ public function validate( TMessage $message, $code, array &$notices );
+}
+
+class_alias( Validator::class, '\MediaWiki\Extensions\Translate\Validator' );
diff --git a/MLEB/Translate/src/Validation/ValidatorFactory.php b/MLEB/Translate/src/Validation/ValidatorFactory.php
new file mode 100644
index 00000000..20561f08
--- /dev/null
+++ b/MLEB/Translate/src/Validation/ValidatorFactory.php
@@ -0,0 +1,118 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation;
+
+use InvalidArgumentException;
+use MediaWiki\Extension\Translate\Validation\Validators\BraceBalanceValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\EscapeCharacterValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\GettextNewlineValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\GettextPluralValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\InsertableRegexValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\InsertableRubyVariableValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\IosVariableValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\MatchSetValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\MediaWikiLinkValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\MediaWikiPageNameValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\MediaWikiParameterValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\MediaWikiPluralValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\MediaWikiTimeListValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\NewlineValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\NumericalParameterValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\PrintfValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\PythonInterpolationValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\ReplacementValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\SmartFormatPluralValidator;
+use MediaWiki\Extension\Translate\Validation\Validators\UnicodePluralValidator;
+use RuntimeException;
+
+/**
+ * A factory class used to instantiate instances of pre-provided Validators
+ *
+ * @author Abijeet Patro
+ * @license GPL-2.0-or-later
+ * @since 2019.06
+ */
+class ValidatorFactory {
+ /** @var string[] */
+ protected static $validators = [
+ 'BraceBalance' => BraceBalanceValidator::class,
+ 'EscapeCharacter' => EscapeCharacterValidator::class,
+ 'GettextNewline' => GettextNewlineValidator::class,
+ 'GettextPlural' => GettextPluralValidator::class,
+ 'InsertableRegex' => InsertableRegexValidator::class,
+ 'InsertableRubyVariable' => InsertableRubyVariableValidator::class,
+ 'IosVariable' => IosVariableValidator::class,
+ 'MatchSet' => MatchSetValidator::class,
+ 'MediaWikiLink' => MediaWikiLinkValidator::class,
+ 'MediaWikiPageName' => MediaWikiPageNameValidator::class,
+ 'MediaWikiParameter' => MediaWikiParameterValidator::class,
+ 'MediaWikiPlural' => MediaWikiPluralValidator::class,
+ 'MediaWikiTimeList' => MediaWikiTimeListValidator::class,
+ 'Newline' => NewlineValidator::class,
+ 'NumericalParameter' => NumericalParameterValidator::class,
+ 'Printf' => PrintfValidator::class,
+ 'PythonInterpolation' => PythonInterpolationValidator::class,
+ 'Replacement' => ReplacementValidator::class,
+ 'SmartFormatPlural' => SmartFormatPluralValidator::class,
+ 'UnicodePlural' => UnicodePluralValidator::class,
+ // BC: remove when unused
+ 'WikiLink' => MediaWikiLinkValidator::class,
+ // BC: remove when unused
+ 'WikiParameter' => MediaWikiParameterValidator::class,
+ ];
+
+ /**
+ * Returns a validator instance based on the id specified
+ *
+ * @param string $id Id of the pre-defined validator class
+ * @param mixed|null $params
+ * @throws InvalidArgumentException
+ * @return MessageValidator
+ */
+ public static function get( $id, $params = null ) {
+ if ( !isset( self::$validators[ $id ] ) ) {
+ throw new InvalidArgumentException( "Could not find validator with id - '$id'. " );
+ }
+
+ return self::loadInstance( self::$validators[ $id ], $params );
+ }
+
+ /**
+ * Takes a Validator class name, and returns an instance of that class.
+ *
+ * @param string $class Custom validator class name
+ * @param mixed|null $params
+ * @throws InvalidArgumentException
+ * @return MessageValidator
+ */
+ public static function loadInstance( $class, $params = null ): MessageValidator {
+ if ( !class_exists( $class ) ) {
+ throw new InvalidArgumentException( "Could not find validator class - '$class'. " );
+ }
+
+ $validator = new $class( $params );
+
+ if ( $validator instanceof Validator ) {
+ return new LegacyValidatorAdapter( $validator );
+ }
+
+ return $validator;
+ }
+
+ /**
+ * Adds / Updates available list of validators
+ * @param string $id Id of the validator
+ * @param string $validator Validator class name
+ * @param string $ns
+ */
+ public static function set( $id, $validator, $ns = '\\' ) {
+ if ( !class_exists( $ns . $validator ) ) {
+ throw new RuntimeException( 'Could not find validator class - ' . $ns . $validator );
+ }
+
+ self::$validators[ $id ] = $ns . $validator;
+ }
+}
+
+class_alias( ValidatorFactory::class, '\MediaWiki\Extensions\Translate\ValidatorFactory' );
diff --git a/MLEB/Translate/src/Validation/Validators/BraceBalanceValidator.php b/MLEB/Translate/src/Validation/Validators/BraceBalanceValidator.php
new file mode 100644
index 00000000..2cc25114
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/BraceBalanceValidator.php
@@ -0,0 +1,57 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use MediaWiki\Extension\Translate\Validation\MessageValidator;
+use MediaWiki\Extension\Translate\Validation\ValidationIssue;
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use TMessage;
+
+/**
+ * Handles brace balance validation
+ * @author Abijeet Patro
+ * @license GPL-2.0-or-later
+ * @since 2019.06
+ */
+class BraceBalanceValidator implements MessageValidator {
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $definition = $message->definition();
+ $translation = $message->translation();
+ $balanceIssues = [];
+ $braceTypes = [
+ [ '{', '}' ],
+ [ '[', ']' ],
+ [ '(', ')' ],
+ ];
+
+ foreach ( $braceTypes as [ $open, $close ] ) {
+ $definitionBalance = $this->getBalance( $definition, $open, $close );
+ $translationBalance = $this->getBalance( $translation, $open, $close );
+
+ if ( $definitionBalance === 0 && $translationBalance !== 0 ) {
+ $balanceIssues[] = "$open$close: $translationBalance";
+ }
+ }
+
+ $issues = new ValidationIssues();
+ if ( $balanceIssues ) {
+ $params = [
+ [ 'PARAMS', $balanceIssues ],
+ [ 'COUNT', count( $balanceIssues ) ],
+ ];
+
+ // Create an issue if braces are unbalanced in translation, but balanced in the definition
+ $issue = new ValidationIssue( 'balance', 'brace', 'translate-checks-balance', $params );
+ $issues->add( $issue );
+ }
+
+ return $issues;
+ }
+
+ private function getBalance( string $source, string $str1, string $str2 ): int {
+ return substr_count( $source, $str1 ) - substr_count( $source, $str2 );
+ }
+}
+
+class_alias( BraceBalanceValidator::class, '\MediaWiki\Extensions\Translate\BraceBalanceValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/EscapeCharacterValidator.php b/MLEB/Translate/src/Validation/Validators/EscapeCharacterValidator.php
new file mode 100644
index 00000000..c333ca98
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/EscapeCharacterValidator.php
@@ -0,0 +1,91 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use InvalidArgumentException;
+use MediaWiki\Extension\Translate\Validation\MessageValidator;
+use MediaWiki\Extension\Translate\Validation\ValidationIssue;
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use TMessage;
+
+/**
+ * Ensures that only the specified escape characters are present.
+ * @license GPL-2.0-or-later
+ * @since 2020.01
+ */
+class EscapeCharacterValidator implements MessageValidator {
+ /** @var string[] */
+ protected $allowedCharacters;
+ /** @var string */
+ protected $regex;
+
+ /** List of valid escape characters recognized. */
+ private const VALID_CHARS = [ '\t', '\n', '\\\'', '\"', '\f', '\r', '\a', '\b', '\\\\' ];
+
+ public function __construct( array $params ) {
+ $this->allowedCharacters = $params['values'] ?? [];
+
+ if ( $this->allowedCharacters === [] || !is_array( $this->allowedCharacters ) ) {
+ throw new InvalidArgumentException(
+ 'No values provided for EscapeCharacter validator.'
+ );
+ }
+
+ $this->regex = $this->buildRegex( $this->allowedCharacters );
+ }
+
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $issues = new ValidationIssues();
+ $translation = $message->translation();
+ preg_match_all( "/$this->regex/U", $translation, $transVars );
+
+ // Check for missing variables in the translation
+ $params = $transVars[0];
+ if ( count( $params ) ) {
+ $messageParams = [
+ [ 'PARAMS', $params ],
+ [ 'COUNT', count( $params ) ],
+ [ 'PARAMS', $this->allowedCharacters ],
+ [ 'COUNT', count( $this->allowedCharacters ) ]
+ ];
+
+ $issue =
+ new ValidationIssue(
+ 'escape', 'invalid', 'translate-checks-escape', $messageParams
+ );
+ $issues->add( $issue );
+ }
+
+ return $issues;
+ }
+
+ private function buildRegex( array $allowedCharacters ): string {
+ $regex = '\\\\[^';
+ $prefix = '';
+ foreach ( $allowedCharacters as $character ) {
+ if ( !in_array( $character, self::VALID_CHARS ) ) {
+ throw new InvalidArgumentException(
+ "Invalid escape character encountered: $character during configuration." .
+ 'Valid escape characters include: ' . implode( ', ', self::VALID_CHARS )
+ );
+ }
+
+ if ( $character !== '\\' ) {
+ $character = stripslashes( $character );
+ // negative look ahead, to avoid "\\ " being treated as an accidental escape
+ $prefix = '(?<!\\\\)';
+ }
+
+ // This is done because in the regex we need slashes for some characters such as
+ // \", \', but not for others such as \n, \t etc
+ $normalizedChar = addslashes( $character );
+ $regex .= $normalizedChar;
+ }
+ $regex .= ']';
+
+ return $prefix . $regex;
+ }
+}
+
+class_alias( EscapeCharacterValidator::class, '\MediaWiki\Extensions\Translate\EscapeCharacterValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/GettextNewlineValidator.php b/MLEB/Translate/src/Validation/Validators/GettextNewlineValidator.php
new file mode 100644
index 00000000..3b52859d
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/GettextNewlineValidator.php
@@ -0,0 +1,49 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use TMessage;
+
+/**
+ * Ensures that the translation has the same number of newlines as the source
+ * message at the beginning and end of the string. This works specifically
+ * for GettextFFS.
+ * @author Abijeet Patro
+ * @license GPL-2.0-or-later
+ * @since 2019.09
+ */
+class GettextNewlineValidator extends NewlineValidator {
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $translation = $message->translation();
+ $definition = $message->definition();
+
+ // ending newlines in GetText are bounded by a "\"
+ $definition = $this->removeTrailingSlash( $definition );
+ $translation = $this->removeTrailingSlash( $translation );
+
+ $definitionStartNewline = $this->getStartingNewLinesCount( $definition );
+ $definitionEndNewline = $this->getEndingNewLineCount( $definition );
+
+ $translationStartNewline = $this->getStartingNewLinesCount( $translation );
+ $translationEndNewline = $this->getEndingNewLineCount( $translation );
+
+ $failingChecks = array_merge(
+ $this->validateStartingNewline( $definitionStartNewline, $translationStartNewline ),
+ $this->validateEndingNewline( $definitionEndNewline, $translationEndNewline )
+ );
+
+ return $this->createIssues( $failingChecks );
+ }
+
+ private function removeTrailingSlash( string $str ): string {
+ if ( substr( $str, -strlen( '\\' ) ) === '\\' ) {
+ return substr( $str, 0, -1 );
+ }
+
+ return $str;
+ }
+}
+
+class_alias( GettextNewlineValidator::class, '\MediaWiki\Extensions\Translate\GettextNewlineValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/GettextPluralValidator.php b/MLEB/Translate/src/Validation/Validators/GettextPluralValidator.php
new file mode 100644
index 00000000..36c6affd
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/GettextPluralValidator.php
@@ -0,0 +1,108 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use MediaWiki\Extension\Translate\Utilities\GettextPlural;
+use MediaWiki\Extension\Translate\Validation\MessageValidator;
+use MediaWiki\Extension\Translate\Validation\ValidationIssue;
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use TMessage;
+
+/**
+ * @license GPL-2.0-or-later
+ * @since 2019.09
+ */
+class GettextPluralValidator implements MessageValidator {
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $issues = new ValidationIssues();
+
+ $pluralRule = GettextPlural::getPluralRule( $targetLanguage );
+ // Skip validation for languages for which we do not know the plural rule
+ if ( !$pluralRule ) {
+ return $issues;
+ }
+
+ $definition = $message->definition();
+ $translation = $message->translation();
+ $expectedPluralCount = GettextPlural::getPluralCount( $pluralRule );
+ $definitionHasPlural = GettextPlural::hasPlural( $definition );
+ $translationHasPlural = GettextPlural::hasPlural( $translation );
+
+ $presence = $this->pluralPresenceCheck(
+ $definitionHasPlural,
+ $translationHasPlural,
+ $expectedPluralCount
+ );
+
+ if ( $presence === 'ok' ) {
+ [ $msgcode, $data ] = $this->pluralFormCountCheck( $translation, $expectedPluralCount );
+ if ( $msgcode === 'invalid-count' ) {
+ $issue = new ValidationIssue(
+ 'plural',
+ 'forms',
+ 'translate-checks-gettext-plural-count',
+ [
+ [ 'COUNT', $expectedPluralCount ],
+ [ 'COUNT', $data[ 'count' ] ],
+ ]
+ );
+ $issues->add( $issue );
+ }
+ } elseif ( $presence === 'missing' ) {
+ $issue = new ValidationIssue(
+ 'plural',
+ 'missing',
+ 'translate-checks-gettext-plural-missing'
+ );
+ $issues->add( $issue );
+ } elseif ( $presence === 'unsupported' ) {
+ $issue = new ValidationIssue(
+ 'plural',
+ 'unsupported',
+ 'translate-checks-gettext-plural-unsupported'
+ );
+ $issues->add( $issue );
+ }
+ // else not-applicable: Plural is not present in translation, but that is fine
+
+ return $issues;
+ }
+
+ private function pluralPresenceCheck(
+ $definitionHasPlural,
+ $translationHasPlural,
+ $expectedPluralCount
+ ) {
+ if ( !$definitionHasPlural && $translationHasPlural ) {
+ return 'unsupported';
+ } elseif ( $definitionHasPlural && !$translationHasPlural ) {
+ if ( $expectedPluralCount > 1 ) {
+ return 'missing';
+ } else {
+ // It's okay to omit plural completely for languages without variance
+ return 'not-applicable';
+ }
+ } elseif ( !$definitionHasPlural && !$translationHasPlural ) {
+ return 'not-applicable';
+ }
+
+ // Both have plural
+ return 'ok';
+ }
+
+ private function pluralFormCountCheck( $text, $expectedPluralCount ) {
+ [ , $instanceMap ] = GettextPlural::parsePluralForms( $text );
+
+ foreach ( $instanceMap as $forms ) {
+ $formsCount = count( $forms );
+ if ( $formsCount !== $expectedPluralCount ) {
+ return [ 'invalid-count', [ 'count' => $formsCount ] ];
+ }
+ }
+
+ return [ 'ok', [] ];
+ }
+}
+
+class_alias( GettextPluralValidator::class, '\MediaWiki\Extensions\Translate\GettextPluralValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/InsertableRegexValidator.php b/MLEB/Translate/src/Validation/Validators/InsertableRegexValidator.php
new file mode 100644
index 00000000..fb056873
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/InsertableRegexValidator.php
@@ -0,0 +1,80 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use InvalidArgumentException;
+use MediaWiki\Extension\Translate\TranslatorInterface\Insertable\RegexInsertablesSuggester;
+use MediaWiki\Extension\Translate\Validation\MessageValidator;
+use MediaWiki\Extension\Translate\Validation\ValidationIssue;
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use TMessage;
+
+/**
+ * A generic regex validator and insertable that can be reused by other classes.
+ * @author Abijeet Patro
+ * @license GPL-2.0-or-later
+ * @since 2019.06
+ */
+class InsertableRegexValidator extends RegexInsertablesSuggester implements MessageValidator {
+ /** @var string */
+ private $validationRegex;
+
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ if ( is_string( $params ) ) {
+ $this->validationRegex = $params;
+ } elseif ( is_array( $params ) ) {
+ $this->validationRegex = $params['regex'] ?? null;
+ }
+
+ if ( $this->validationRegex === null ) {
+ throw new InvalidArgumentException( 'The configuration for InsertableRegexValidator does not ' .
+ 'specify a regular expression.' );
+ }
+ }
+
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $issues = new ValidationIssues();
+
+ preg_match_all( $this->validationRegex, $message->definition(), $definitionMatch );
+ preg_match_all( $this->validationRegex, $message->translation(), $translationMatch );
+ $definitionVariables = $definitionMatch[0];
+ $translationVariables = $translationMatch[0];
+
+ $missingVariables = array_diff( $definitionVariables, $translationVariables );
+ if ( $missingVariables ) {
+ $issue = new ValidationIssue(
+ 'variable',
+ 'missing',
+ 'translate-checks-parameters',
+ [
+ [ 'PLAIN-PARAMS', $missingVariables ],
+ [ 'COUNT', count( $missingVariables ) ]
+ ]
+ );
+
+ $issues->add( $issue );
+ }
+
+ $unknownVariables = array_diff( $translationVariables, $definitionVariables );
+ if ( $unknownVariables ) {
+ $issue = new ValidationIssue(
+ 'variable',
+ 'unknown',
+ 'translate-checks-parameters-unknown',
+ [
+ [ 'PLAIN-PARAMS', $unknownVariables ],
+ [ 'COUNT', count( $unknownVariables ) ]
+ ]
+ );
+
+ $issues->add( $issue );
+ }
+
+ return $issues;
+ }
+}
+
+class_alias( InsertableRegexValidator::class, '\MediaWiki\Extensions\Translate\InsertableRegexValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/InsertableRubyVariableValidator.php b/MLEB/Translate/src/Validation/Validators/InsertableRubyVariableValidator.php
new file mode 100644
index 00000000..50b67214
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/InsertableRubyVariableValidator.php
@@ -0,0 +1,21 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+/**
+ * An insertable Ruby variable validator that also acts as an InsertableSuggester
+ * @author Abijeet Patro
+ * @license GPL-2.0-or-later
+ * @since 2019.06
+ */
+class InsertableRubyVariableValidator extends InsertableRegexValidator {
+ public function __construct() {
+ parent::__construct( '/%{[a-zA-Z_]+}/' );
+ }
+}
+
+class_alias(
+ InsertableRubyVariableValidator::class,
+ '\MediaWiki\Extensions\Translate\InsertableRubyVariableValidator'
+);
diff --git a/MLEB/Translate/src/Validation/Validators/IosVariableValidator.php b/MLEB/Translate/src/Validation/Validators/IosVariableValidator.php
new file mode 100644
index 00000000..c981e194
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/IosVariableValidator.php
@@ -0,0 +1,23 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+// phpcs:disable Generic.Files.LineLength.TooLong
+/**
+ * An insertable IOS variable validator.
+ * See: https://github.com/dcordero/Rubustrings/blob/61d477bffbb318ca3ffed9c2afc49ec301931d93/lib/rubustrings/action.rb#L91
+ * @author Abijeet Patro
+ * @license GPL-2.0-or-later
+ * @since 2020.03
+ */
+class IosVariableValidator extends InsertableRegexValidator {
+ public function __construct() {
+ parent::__construct(
+ "/%(?:([1-9]\d*)\$|\(([^\)]+)\))?(\+)?(0|\'[^$])?" .
+ "(-)?(\d+)?(?:\.(\d+))?(hh|ll|[hlLzjt])?([b-fiosuxX@])/"
+ );
+ }
+}
+
+class_alias( IosVariableValidator::class, '\MediaWiki\Extensions\Translate\IosVariableValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/MatchSetValidator.php b/MLEB/Translate/src/Validation/Validators/MatchSetValidator.php
new file mode 100644
index 00000000..a81bba20
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/MatchSetValidator.php
@@ -0,0 +1,66 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use InvalidArgumentException;
+use MediaWiki\Extension\Translate\Validation\MessageValidator;
+use MediaWiki\Extension\Translate\Validation\ValidationIssue;
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use TMessage;
+
+/**
+ * Ensures that the translation for a message matches a value from a list.
+ * @license GPL-2.0-or-later
+ * @since 2019.12
+ */
+class MatchSetValidator implements MessageValidator {
+ /** @var string[] */
+ protected $possibleValues;
+ /** @var string[] */
+ protected $normalizedValues;
+ /** @var bool */
+ protected $caseSensitive;
+
+ public function __construct( array $params ) {
+ $this->possibleValues = $params['values'] ?? [];
+ $this->caseSensitive = (bool)( $params['caseSensitive'] ?? true );
+
+ if ( $this->possibleValues === [] ) {
+ throw new InvalidArgumentException( 'No values provided for MatchSet validator.' );
+ }
+
+ if ( $this->caseSensitive ) {
+ $this->normalizedValues = $this->possibleValues;
+ } else {
+ $this->normalizedValues = array_map( 'strtolower', $this->possibleValues );
+ }
+ }
+
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $issues = new ValidationIssues();
+
+ $translation = $message->translation();
+ if ( $this->caseSensitive ) {
+ $translation = strtolower( $translation );
+ }
+
+ if ( array_search( $translation, $this->normalizedValues, true ) === false ) {
+ $issue = new ValidationIssue(
+ 'value-not-present',
+ 'invalid',
+ 'translate-checks-value-not-present',
+ [
+ [ 'PLAIN-PARAMS', $this->possibleValues ],
+ [ 'COUNT', count( $this->possibleValues ) ]
+ ]
+ );
+
+ $issues->add( $issue );
+ }
+
+ return $issues;
+ }
+}
+
+class_alias( MatchSetValidator::class, '\MediaWiki\Extensions\Translate\MatchSetValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/MediaWikiLinkValidator.php b/MLEB/Translate/src/Validation/Validators/MediaWikiLinkValidator.php
new file mode 100644
index 00000000..0f872c6e
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/MediaWikiLinkValidator.php
@@ -0,0 +1,74 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use MediaWiki\Extension\Translate\Validation\MessageValidator;
+use MediaWiki\Extension\Translate\Validation\ValidationIssue;
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use Title;
+use TMessage;
+
+/**
+ * Checks if the translation uses links that are discouraged. Valid links are those that link
+ * to Special: or {{ns:special}}: or project pages trough MediaWiki messages like
+ * {{MediaWiki:helppage-url}}:. Also links in the definition are allowed.
+ * @license GPL-2.0-or-later
+ * @since 2020.02
+ */
+class MediaWikiLinkValidator implements MessageValidator {
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $issues = new ValidationIssues();
+
+ $definition = $message->definition();
+ $translation = $message->translation();
+
+ $links = $this->getLinksMissingInTarget( $definition, $translation );
+ if ( $links !== [] ) {
+ $issue = new ValidationIssue(
+ 'links',
+ 'missing',
+ 'translate-checks-links-missing',
+ [
+ [ 'PARAMS', $links ],
+ [ 'COUNT', count( $links ) ],
+ ]
+ );
+ $issues->add( $issue );
+ }
+
+ $links = $this->getLinksMissingInTarget( $translation, $definition );
+ if ( $links !== [] ) {
+ $issue = new ValidationIssue(
+ 'links',
+ 'extra',
+ 'translate-checks-links',
+ [
+ [ 'PARAMS', $links ],
+ [ 'COUNT', count( $links ) ],
+ ]
+ );
+ $issues->add( $issue );
+ }
+
+ return $issues;
+ }
+
+ private function getLinksMissingInTarget( string $source, string $target ): array {
+ $tc = Title::legalChars() . '#%{}';
+ $matches = $links = [];
+
+ preg_match_all( "/\[\[([{$tc}]+)(\\|(.+?))?]]/sDu", $source, $matches );
+ $count = count( $matches[0] );
+ for ( $i = 0; $i < $count; $i++ ) {
+ $backMatch = preg_quote( $matches[1][$i], '/' );
+ if ( preg_match( "/\[\[$backMatch/", $target ) !== 1 ) {
+ $links[] = "[[{$matches[1][$i]}{$matches[2][$i]}]]";
+ }
+ }
+
+ return $links;
+ }
+}
+
+class_alias( MediaWikiLinkValidator::class, '\MediaWiki\Extensions\Translate\MediaWikiLinkValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/MediaWikiPageNameValidator.php b/MLEB/Translate/src/Validation/Validators/MediaWikiPageNameValidator.php
new file mode 100644
index 00000000..344c5108
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/MediaWikiPageNameValidator.php
@@ -0,0 +1,41 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use MediaWiki\Extension\Translate\Validation\MessageValidator;
+use MediaWiki\Extension\Translate\Validation\ValidationIssue;
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use TMessage;
+
+/**
+ * Ensures that translations do not translate namespaces.
+ * @author Abijeet Patro
+ * @license GPL-2.0-or-later
+ * @since 2020.02
+ */
+class MediaWikiPageNameValidator implements MessageValidator {
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $issues = new ValidationIssues();
+
+ $definition = $message->definition();
+ $translation = $message->translation();
+
+ $namespaces = 'help|project|\{\{ns:project}}|mediawiki';
+ $matches = [];
+ if ( preg_match( "/^($namespaces):[\w\s]+$/ui", $definition, $matches ) &&
+ !preg_match( "/^{$matches[1]}:.+$/u", $translation )
+ ) {
+ $issue = new ValidationIssue(
+ 'pagename',
+ 'namespace',
+ 'translate-checks-pagename'
+ );
+ $issues->add( $issue );
+ }
+
+ return $issues;
+ }
+}
+
+class_alias( MediaWikiPageNameValidator::class, '\MediaWiki\Extensions\Translate\MediaWikiPageNameValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/MediaWikiParameterValidator.php b/MLEB/Translate/src/Validation/Validators/MediaWikiParameterValidator.php
new file mode 100644
index 00000000..5c953b4c
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/MediaWikiParameterValidator.php
@@ -0,0 +1,17 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+/**
+ * An insertable wiki parameter validator that also acts as an InsertableSuggester
+ * @license GPL-2.0-or-later
+ * @since 2019.12
+ */
+class MediaWikiParameterValidator extends InsertableRegexValidator {
+ public function __construct() {
+ parent::__construct( '/\$[1-9]/' );
+ }
+}
+
+class_alias( MediaWikiParameterValidator::class, '\MediaWiki\Extensions\Translate\MediaWikiParameterValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/MediaWikiPluralValidator.php b/MLEB/Translate/src/Validation/Validators/MediaWikiPluralValidator.php
new file mode 100644
index 00000000..b7606d8f
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/MediaWikiPluralValidator.php
@@ -0,0 +1,147 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use Language;
+use MediaWiki\Extension\Translate\Validation\MessageValidator;
+use MediaWiki\Extension\Translate\Validation\ValidationIssue;
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use MediaWiki\MediaWikiServices;
+use Parser;
+use ParserOptions;
+use PPFrame;
+use TMessage;
+use User;
+
+/**
+ * Handles plural validation for MediaWiki inline plural syntax.
+ * @author Abijeet Patro
+ * @license GPL-2.0-or-later
+ * @since 2019.06
+ */
+class MediaWikiPluralValidator implements MessageValidator {
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $issues = new ValidationIssues();
+ $this->pluralCheck( $message, $issues );
+ $this->pluralFormsCheck( $message, $targetLanguage, $issues );
+
+ return $issues;
+ }
+
+ private function pluralCheck( TMessage $message, ValidationIssues $issues ): void {
+ $definition = $message->definition();
+ $translation = $message->translation();
+
+ if (
+ stripos( $definition, '{{plural:' ) !== false &&
+ stripos( $translation, '{{plural:' ) === false
+ ) {
+ $issue = new ValidationIssue( 'plural', 'missing', 'translate-checks-plural' );
+ $issues->add( $issue );
+ }
+ }
+
+ protected function pluralFormsCheck(
+ TMessage $message, string $code, ValidationIssues $issues
+ ): void {
+ $translation = $message->translation();
+ // Are there any plural forms for this language in this message?
+ if ( stripos( $translation, '{{plural:' ) === false ) {
+ return;
+ }
+
+ $plurals = self::getPluralForms( $translation );
+ $allowed = self::getPluralFormCount( $code );
+
+ foreach ( $plurals as $forms ) {
+ $forms = self::removeExplicitPluralForms( $forms );
+ $provided = count( $forms );
+
+ if ( $provided > $allowed ) {
+ $issue = new ValidationIssue(
+ 'plural',
+ 'forms',
+ 'translate-checks-plural-forms',
+ [
+ [ 'COUNT', $provided ],
+ [ 'COUNT', $allowed ],
+ ]
+ );
+
+ $issues->add( $issue );
+ }
+
+ // Are the last two forms identical?
+ if ( $provided > 1 && $forms[$provided - 1] === $forms[$provided - 2] ) {
+ $issue = new ValidationIssue( 'plural', 'dupe', 'translate-checks-plural-dupe' );
+ $issues->add( $issue );
+ }
+ }
+ }
+
+ /** Returns the number of plural forms %MediaWiki supports for a language. */
+ public static function getPluralFormCount( string $code ): int {
+ $forms = Language::factory( $code )->getPluralRules();
+
+ // +1 for the 'other' form
+ return count( $forms ) + 1;
+ }
+
+ /**
+ * Ugly home made probably awfully slow looping parser that parses {{PLURAL}} instances from
+ * a message and returns array of invocations having array of forms.
+ *
+ * @return array[]
+ */
+ public static function getPluralForms( string $translation ): array {
+ // Stores the forms from plural invocations
+ $plurals = [];
+
+ $cb = function ( $parser, $frame, $args ) use ( &$plurals ) {
+ $forms = [];
+
+ foreach ( $args as $index => $form ) {
+ // The first arg is the number, we skip it
+ if ( $index !== 0 ) {
+ // Collect the raw text
+ $forms[] = $frame->expand( $form, PPFrame::RECOVER_ORIG );
+ // Expand the text to process embedded plurals
+ $frame->expand( $form );
+ }
+ }
+ $plurals[] = $forms;
+
+ return '';
+ };
+
+ // Setup parser
+ $parser = MediaWikiServices::getInstance()->getParserFactory()->create();
+ // Load the default magic words etc now.
+ $parser->firstCallInit();
+ // So that they don't overrider our own callback
+ $parser->setFunctionHook( 'plural', $cb, Parser::SFH_NO_HASH | Parser::SFH_OBJECT_ARGS );
+
+ // Setup things needed for preprocess
+ $title = null;
+ $options = new ParserOptions( new User(), Language::factory( 'en' ) );
+
+ $parser->preprocess( $translation, $title, $options );
+
+ return $plurals;
+ }
+
+ /** Remove forms that start with an explicit number. */
+ public static function removeExplicitPluralForms( array $forms ): array {
+ // Handle explicit 0= and 1= forms
+ foreach ( $forms as $index => $form ) {
+ if ( preg_match( '/^[0-9]+=/', $form ) ) {
+ unset( $forms[$index] );
+ }
+ }
+
+ return array_values( $forms );
+ }
+}
+
+class_alias( MediaWikiPluralValidator::class, '\MediaWiki\Extensions\Translate\MediaWikiPluralValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/MediaWikiTimeListValidator.php b/MLEB/Translate/src/Validation/Validators/MediaWikiTimeListValidator.php
new file mode 100644
index 00000000..e1fb1279
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/MediaWikiTimeListValidator.php
@@ -0,0 +1,84 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use MediaWiki\Extension\Translate\Validation\MessageValidator;
+use MediaWiki\Extension\Translate\Validation\ValidationIssue;
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use TMessage;
+
+/**
+ * "Time list" message format validation for MediaWiki.
+ *
+ * @author Abijeet Patro
+ * @license GPL-2.0-or-later
+ * @since 2019.06
+ */
+class MediaWikiTimeListValidator implements MessageValidator {
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $issues = new ValidationIssues();
+
+ $definition = $message->definition();
+ $translation = $message->translation();
+ $defArray = explode( ',', $definition );
+ $traArray = explode( ',', $translation );
+
+ $defCount = count( $defArray );
+ $traCount = count( $traArray );
+ if ( $defCount !== $traCount ) {
+ $issue = new ValidationIssue(
+ 'miscmw',
+ 'timelist-count',
+ 'translate-checks-format',
+ [
+ [
+ 'MESSAGE',
+ [
+ 'translate-checks-parametersnotequal',
+ [ 'COUNT', $traCount ],
+ [ 'COUNT', $defCount ],
+ ]
+ ]
+ ]
+ );
+ $issues->add( $issue );
+
+ return $issues;
+ }
+
+ for ( $i = 0; $i < $defCount; $i++ ) {
+ $defItems = array_map( 'trim', explode( ':', $defArray[$i] ) );
+ $traItems = array_map( 'trim', explode( ':', $traArray[$i] ) );
+
+ if ( count( $traItems ) !== 2 ) {
+ $issue = new ValidationIssue(
+ 'miscmw',
+ 'timelist-format',
+ 'translate-checks-format',
+ [ [ 'MESSAGE', [ 'translate-checks-malformed', $traArray[$i] ] ] ]
+ );
+
+ $issues->add( $issue );
+ continue;
+ }
+
+ if ( $traItems[1] !== $defItems[1] ) {
+ $issue = new ValidationIssue(
+ 'miscmw',
+ 'timelist-format-value',
+ 'translate-checks-format',
+ // FIXME: i18n missing.
+ [ "<samp><nowiki>$traItems[1] !== $defItems[1]</nowiki></samp>" ]
+ );
+
+ $issues->add( $issue );
+ continue;
+ }
+ }
+
+ return $issues;
+ }
+}
+
+class_alias( MediaWikiTimeListValidator::class, '\MediaWiki\Extensions\Translate\MediaWikiTimeListValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/NewlineValidator.php b/MLEB/Translate/src/Validation/Validators/NewlineValidator.php
new file mode 100644
index 00000000..36525569
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/NewlineValidator.php
@@ -0,0 +1,102 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use MediaWiki\Extension\Translate\Validation\MessageValidator;
+use MediaWiki\Extension\Translate\Validation\ValidationIssue;
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use TMessage;
+
+/**
+ * Ensures that the translation has the same number of newlines as the source
+ * message at the beginning of the string.
+ * @author Abijeet Patro
+ * @license GPL-2.0-or-later
+ * @since 2019.09
+ */
+class NewlineValidator implements MessageValidator {
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $translation = $message->translation();
+ $definition = $message->definition();
+
+ $definitionStartNewline = $this->getStartingNewLinesCount( $definition );
+ $translationStartNewline = $this->getStartingNewLinesCount( $translation );
+
+ $failingChecks = $this->validateStartingNewline(
+ $definitionStartNewline, $translationStartNewline
+ );
+
+ return $this->createIssues( $failingChecks );
+ }
+
+ protected function getStartingNewLinesCount( string $str ): int {
+ return strspn( $str, "\n" );
+ }
+
+ protected function getEndingNewLineCount( string $str ): int {
+ return strspn( strrev( $str ), "\n" );
+ }
+
+ protected function validateStartingNewline(
+ int $definitionStartNewline,
+ int $translationStartNewline
+ ): array {
+ $failingChecks = [];
+ if ( $definitionStartNewline < $translationStartNewline ) {
+ // Extra whitespace at beginning
+ $failingChecks[] = [
+ 'extra-start',
+ $translationStartNewline - $definitionStartNewline
+ ];
+ } elseif ( $definitionStartNewline > $translationStartNewline ) {
+ // Missing whitespace at beginning
+ $failingChecks[] = [
+ 'missing-start',
+ $definitionStartNewline - $translationStartNewline
+ ];
+ }
+
+ return $failingChecks;
+ }
+
+ protected function validateEndingNewline(
+ int $definitionEndNewline,
+ int $translationEndNewline
+ ): array {
+ $failingChecks = [];
+ if ( $definitionEndNewline < $translationEndNewline ) {
+ // Extra whitespace at end
+ $failingChecks[] = [
+ 'extra-end',
+ $translationEndNewline - $definitionEndNewline
+ ];
+ } elseif ( $definitionEndNewline > $translationEndNewline ) {
+ // Missing whitespace at end
+ $failingChecks[] = [
+ 'missing-end',
+ $definitionEndNewline - $translationEndNewline
+ ];
+ }
+
+ return $failingChecks;
+ }
+
+ protected function createIssues( array $failingChecks ): ValidationIssues {
+ $issues = new ValidationIssues();
+ foreach ( $failingChecks as [ $subType, $count ] ) {
+ $issue = new ValidationIssue(
+ 'newline',
+ $subType,
+ "translate-checks-newline-$subType",
+ [ 'COUNT', $count ]
+ );
+
+ $issues->add( $issue );
+ }
+
+ return $issues;
+ }
+}
+
+class_alias( NewlineValidator::class, '\MediaWiki\Extensions\Translate\NewlineValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/NumericalParameterValidator.php b/MLEB/Translate/src/Validation/Validators/NumericalParameterValidator.php
new file mode 100644
index 00000000..84bbf0e3
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/NumericalParameterValidator.php
@@ -0,0 +1,17 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+/**
+ * An insertable numerical parameter validator that also acts as an InsertableSuggester
+ * @license GPL-2.0-or-later
+ * @since 2020.03
+ */
+class NumericalParameterValidator extends InsertableRegexValidator {
+ public function __construct() {
+ parent::__construct( '/\$\d+/' );
+ }
+}
+
+class_alias( NumericalParameterValidator::class, '\MediaWiki\Extensions\Translate\NumericalParameterValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/PrintfValidator.php b/MLEB/Translate/src/Validation/Validators/PrintfValidator.php
new file mode 100644
index 00000000..6121e002
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/PrintfValidator.php
@@ -0,0 +1,18 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+/**
+ * A validator that checks for missing and unknown printf formatting characters
+ * in translations. Can also be used as an Insertable suggester
+ * @license GPL-2.0-or-later
+ * @since 2019.12
+ */
+class PrintfValidator extends InsertableRegexValidator {
+ public function __construct() {
+ parent::__construct( '/%(\d+\$)?(\.\d+)?[sduf]/U' );
+ }
+}
+
+class_alias( PrintfValidator::class, '\MediaWiki\Extensions\Translate\PrintfValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/PythonInterpolationValidator.php b/MLEB/Translate/src/Validation/Validators/PythonInterpolationValidator.php
new file mode 100644
index 00000000..ca9a98e3
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/PythonInterpolationValidator.php
@@ -0,0 +1,18 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+/**
+ * An insertable python interpolation validator that also acts as an InsertableSuggester
+ * @author Abijeet Patro
+ * @license GPL-2.0-or-later
+ * @since 2020.02
+ */
+class PythonInterpolationValidator extends InsertableRegexValidator {
+ public function __construct() {
+ parent::__construct( '/\%(?:\([a-zA-Z0-9_]*?\))?[diouxXeEfFgGcrs]/U' );
+ }
+}
+
+class_alias( PythonInterpolationValidator::class, '\MediaWiki\Extensions\Translate\PythonInterpolationValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/ReplacementValidator.php b/MLEB/Translate/src/Validation/Validators/ReplacementValidator.php
new file mode 100644
index 00000000..46b5382c
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/ReplacementValidator.php
@@ -0,0 +1,54 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use InvalidArgumentException;
+use MediaWiki\Extension\Translate\Validation\MessageValidator;
+use MediaWiki\Extension\Translate\Validation\ValidationIssue;
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use TMessage;
+
+/**
+ * @author Niklas Laxström
+ * @license GPL-2.0-or-later
+ * @since 2020.07
+ */
+class ReplacementValidator implements MessageValidator {
+ private $search;
+ private $replace;
+
+ public function __construct( array $params ) {
+ $this->search = $params['search'] ?? null;
+ $this->replace = $params['replace'] ?? null;
+ if ( !is_string( $this->search ) ) {
+ throw new InvalidArgumentException( '`search` is not a string' );
+ }
+
+ if ( !is_string( $this->replace ) ) {
+ throw new InvalidArgumentException( '`replace` is not a string' );
+ }
+ }
+
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $issues = new ValidationIssues();
+
+ if ( strpos( $message->translation(), $this->search ) !== false ) {
+ $issue = new ValidationIssue(
+ 'replacement',
+ 'replacement',
+ 'translate-checks-replacement',
+ [
+ [ 'PLAIN', $this->search ],
+ [ 'PLAIN', $this->replace ],
+ ]
+ );
+
+ $issues->add( $issue );
+ }
+
+ return $issues;
+ }
+}
+
+class_alias( ReplacementValidator::class, '\MediaWiki\Extensions\Translate\ReplacementValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/SmartFormatPluralValidator.php b/MLEB/Translate/src/Validation/Validators/SmartFormatPluralValidator.php
new file mode 100644
index 00000000..41d3ea44
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/SmartFormatPluralValidator.php
@@ -0,0 +1,112 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use MediaWiki\Extension\Translate\TranslatorInterface\Insertable\Insertable;
+use MediaWiki\Extension\Translate\TranslatorInterface\Insertable\InsertablesSuggester;
+use MediaWiki\Extension\Translate\Utilities\SmartFormatPlural;
+use MediaWiki\Extension\Translate\Utilities\UnicodePlural;
+use MediaWiki\Extension\Translate\Validation\MessageValidator;
+use MediaWiki\Extension\Translate\Validation\ValidationIssue;
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use TMessage;
+
+/**
+ * @license GPL-2.0-or-later
+ * @since 2019.11
+ */
+class SmartFormatPluralValidator implements MessageValidator, InsertablesSuggester {
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $issues = new ValidationIssues();
+
+ $expectedKeywords = UnicodePlural::getPluralKeywords( $targetLanguage );
+ // Skip validation for languages for which we do not know the plural rule
+ if ( $expectedKeywords === null ) {
+ return $issues;
+ }
+
+ $definition = $message->definition();
+ $translation = $message->translation();
+ $expectedPluralCount = count( $expectedKeywords );
+ $definitionPlurals = SmartFormatPlural::getPluralInstances( $definition );
+ $translationPlurals = SmartFormatPlural::getPluralInstances( $translation );
+
+ $unsupportedVariables = array_diff(
+ array_keys( $translationPlurals ), array_keys( $definitionPlurals )
+ );
+
+ foreach ( $unsupportedVariables as $unsupportedVariable ) {
+ $issue = new ValidationIssue(
+ 'plural',
+ 'unsupported',
+ 'translate-checks-smartformat-plural-unsupported',
+ [
+ [ 'PLAIN', '{' . $unsupportedVariable . '}' ],
+ ]
+ );
+
+ $issues->add( $issue );
+ }
+
+ if ( $expectedPluralCount > 1 ) {
+ $missingVariables = array_diff(
+ array_keys( $definitionPlurals ), array_keys( $translationPlurals )
+ );
+
+ foreach ( $missingVariables as $missingVariable ) {
+ $issue = new ValidationIssue(
+ 'plural',
+ 'missing',
+ 'translate-checks-smartformat-plural-missing',
+ [
+ [ 'PLAIN', '{' . $missingVariable . '}' ],
+ ]
+ );
+
+ $issues->add( $issue );
+ }
+ }
+
+ // This returns only translation plurals for variables that exists in source
+ $commonVariables = array_intersect_key( $translationPlurals, $definitionPlurals );
+ foreach ( $commonVariables as $pluralInstances ) {
+ foreach ( $pluralInstances as $pluralInstance ) {
+ $actualPluralCount = count( $pluralInstance[ 'forms' ] );
+ if ( $actualPluralCount !== $expectedPluralCount ) {
+ $issue = new ValidationIssue(
+ 'plural',
+ 'forms',
+ 'translate-checks-smartformat-plural-count',
+ [
+ [ 'COUNT', $expectedPluralCount ],
+ [ 'COUNT', $actualPluralCount ],
+ [ 'PLAIN', $pluralInstance[ 'original' ] ],
+ ]
+ );
+
+ $issues->add( $issue );
+ }
+ }
+ }
+
+ return $issues;
+ }
+
+ public function getInsertables( string $text ): array {
+ $definitionPlurals = SmartFormatPlural::getPluralInstances( $text );
+ $insertables = [];
+
+ // This could be more language specific if we were given more information, but
+ // we only have text.
+ foreach ( array_keys( $definitionPlurals ) as $variable ) {
+ $pre = '{' . "$variable:";
+ $post = '|}';
+ $insertables[] = new Insertable( "$pre$post", $pre, $post );
+ }
+
+ return $insertables;
+ }
+}
+
+class_alias( SmartFormatPluralValidator::class, '\MediaWiki\Extensions\Translate\SmartFormatPluralValidator' );
diff --git a/MLEB/Translate/src/Validation/Validators/UnicodePluralValidator.php b/MLEB/Translate/src/Validation/Validators/UnicodePluralValidator.php
new file mode 100644
index 00000000..da6a9eef
--- /dev/null
+++ b/MLEB/Translate/src/Validation/Validators/UnicodePluralValidator.php
@@ -0,0 +1,112 @@
+<?php
+declare( strict_types = 1 );
+
+namespace MediaWiki\Extension\Translate\Validation\Validators;
+
+use MediaWiki\Extension\Translate\Utilities\UnicodePlural;
+use MediaWiki\Extension\Translate\Validation\MessageValidator;
+use MediaWiki\Extension\Translate\Validation\ValidationIssue;
+use MediaWiki\Extension\Translate\Validation\ValidationIssues;
+use TMessage;
+
+/**
+ * This is a very strict validator class for Unicode CLDR based plural markup.
+ *
+ * It requires all forms to be present and in correct order. Whitespace around keywords
+ * and values is trimmed. The keyword `other` is left out, though it is allowed in input.
+ * @since 2019.09
+ * @license GPL-2.0-or-later
+ */
+class UnicodePluralValidator implements MessageValidator {
+ public function getIssues( TMessage $message, string $targetLanguage ): ValidationIssues {
+ $issues = new ValidationIssues();
+
+ $expectedKeywords = UnicodePlural::getPluralKeywords( $targetLanguage );
+ // Skip validation for languages for which we do not know the plural rule
+ if ( $expectedKeywords === null ) {
+ return $issues;
+ }
+
+ $definition = $message->definition();
+ $translation = $message->translation();
+ $definitionHasPlural = UnicodePlural::hasPlural( $definition );
+ $translationHasPlural = UnicodePlural::hasPlural( $translation );
+
+ $presence = $this->pluralPresenceCheck(
+ $definitionHasPlural,
+ $translationHasPlural
+ );
+
+ // Using same check keys as MediaWikiPluralValidator
+ if ( $presence === 'missing' ) {
+ $issue = new ValidationIssue( 'plural', 'missing', 'translate-checks-unicode-plural-missing' );
+ $issues->add( $issue );
+ } elseif ( $presence === 'unsupported' ) {
+ $issue = new ValidationIssue( 'plural', 'unsupported', 'translate-checks-unicode-plural-unsupported' );
+ $issues->add( $issue );
+ } elseif ( $presence === 'ok' ) {
+ [ $msgcode, $actualKeywords ] =
+ $this->pluralFormCheck( $translation, $expectedKeywords );
+ if ( $msgcode === 'invalid' ) {
+ $expectedExample = UnicodePlural::flattenList(
+ array_map( [ $this, 'createFormExample' ], $expectedKeywords )
+ );
+ $actualExample = UnicodePlural::flattenList(
+ array_map( [ $this, 'createFormExample' ], $actualKeywords )
+ );
+
+ $issue = new ValidationIssue(
+ 'plural',
+ 'forms',
+ 'translate-checks-unicode-plural-invalid',
+ [
+ [ 'PLAIN', $expectedExample ],
+ [ 'PLAIN', $actualExample ],
+ ]
+ );
+ $issues->add( $issue );
+ }
+ } // else: not-applicable
+
+ return $issues;
+ }
+
+ private function createFormExample( string $keyword ): array {
+ return [ $keyword, '…' ];
+ }
+
+ private function pluralPresenceCheck(
+ bool $definitionHasPlural,
+ bool $translationHasPlural
+ ): string {
+ if ( !$definitionHasPlural && $translationHasPlural ) {
+ return 'unsupported';
+ } elseif ( $definitionHasPlural && !$translationHasPlural ) {
+ return 'missing';
+ } elseif ( !$definitionHasPlural && !$translationHasPlural ) {
+ return 'not-applicable';
+ }
+
+ // Both have plural
+ return 'ok';
+ }
+
+ private function pluralFormCheck( string $text, array $expectedKeywords ): array {
+ [ , $instanceMap ] = UnicodePlural::parsePluralForms( $text );
+
+ foreach ( $instanceMap as $forms ) {
+ $actualKeywords = [];
+ foreach ( $forms as [ $keyword, ] ) {
+ $actualKeywords[] = $keyword;
+ }
+
+ if ( $actualKeywords !== $expectedKeywords ) {
+ return [ 'invalid', $actualKeywords ];
+ }
+ }
+
+ return [ 'ok', [] ];
+ }
+}
+
+class_alias( UnicodePluralValidator::class, '\MediaWiki\Extensions\Translate\UnicodePluralValidator' );