diff options
Diffstat (limited to 'MLEB/Translate/src/Validation')
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' ); |