diff --git a/CHANGELOG.md b/CHANGELOG.md index 10b3aa25..eda06eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). Previous releases are documented in [github releases](https://github.com/oscarotero/Gettext/releases) +## [5.7.0] - 2021-07-27 +### Added +- StrictPoLoader, a stricter PO loader more aligned with the syntax of the GNU gettext tooling [#282]. +- Previous attributes (msgctxt, msgid, msgid_plural) to the Translation class and the PO generator [#282]. +### Changed +- Minor performance improvements to the Translations class [#282]. + ## [5.6.1] - 2021-12-04 ### Fixed - PHP 8.1 support [#278]. @@ -112,7 +119,9 @@ Previous releases are documented in [github releases](https://github.com/oscarot [#265]: https://github.com/php-gettext/Gettext/issues/265 [#276]: https://github.com/php-gettext/Gettext/issues/276 [#278]: https://github.com/php-gettext/Gettext/issues/278 +[#282]: https://github.com/php-gettext/Gettext/issues/282 +[5.7.0]: https://github.com/php-gettext/Gettext/compare/v5.6.1...v5.7.0 [5.6.1]: https://github.com/php-gettext/Gettext/compare/v5.6.0...v5.6.1 [5.6.0]: https://github.com/php-gettext/Gettext/compare/v5.5.4...v5.6.0 [5.5.4]: https://github.com/php-gettext/Gettext/compare/v5.5.3...v5.5.4 diff --git a/README.md b/README.md index 0d181c9a..cb6eb9ce 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ $translations->setDomain('my-blog'); ## Loaders -The loaders allows to get gettext values from any format. For example, to load a .po file: +The loaders allow to get gettext values from multiple formats. For example, to load a .po file: ```php use Gettext\Loader\PoLoader; @@ -109,10 +109,39 @@ $string = file_get_contents('locales2/en.po'); $translations = $loader->loadString($string); ``` +As of version 5.7.0, a `StrictPoLoader` has been included, with a parser more aligned to the GNU gettext tooling with the same expectations and failures (see the tests for more details). +- It will fail with an exception when there's anything wrong with the syntax, and display the reason together with the line/byte where it happened. +- It might also emit useful warnings, e.g. when there are more/less plural translations than needed, missing translation header, dangling comments not associated with any translation, etc. +- Due to its strictness and speed (about 50% slower than the `PoLoader`), it might be interesting to be used as a kind of `.po` linter in a build system. +- It also implements the previous translation comment (e.g. `#| msgid "previous"`) and extra escapes (16-bit unicode `\u`, 32-bit unicode `\U`, hexadecimal `\xFF` and octal `\77`). + +The usage is basically the same as the `PoLoader`: + +```php +use Gettext\Loader\StrictPoLoader; + +$loader = new StrictPoLoader(); + +//From a file +$translations = $loader->loadFile('locales/en.po'); + +//From a string +$string = file_get_contents('locales2/en.po'); +$translations = $loader->loadString($string); + +//Display error messages using "at line X column Y" instead of "at byte X" +$loader->displayErrorLine = true; +//Throw an exception when a warning happens +$loader->throwOnWarning = true; +//Retrieve the warnings +$loader->getWarnings(); +``` + This package includes the following loaders: - `MoLoader` - `PoLoader` +- `StrictPoLoader` And you can install other formats with loaders and generators: diff --git a/src/Generator/PoGenerator.php b/src/Generator/PoGenerator.php index 0dc8de8f..000c1a6a 100644 --- a/src/Generator/PoGenerator.php +++ b/src/Generator/PoGenerator.php @@ -65,6 +65,18 @@ public function generateString(Translations $translations): string $prefix = $translation->isDisabled() ? '#~ ' : ''; + if ($context = $translation->getPreviousContext()) { + $lines[] = sprintf('%s#| msgctxt %s', $prefix, self::encode($context)); + } + + if ($original = $translation->getPreviousOriginal()) { + $lines[] = sprintf('%s#| msgid %s', $prefix, self::encode($original)); + } + + if ($plural = $translation->getPreviousPlural()) { + $lines[] = sprintf('%s#| msgid_plural %s', $prefix, self::encode($plural)); + } + if ($context = $translation->getContext()) { $lines[] = sprintf('%smsgctxt %s', $prefix, self::encode($context)); } diff --git a/src/Loader/MoLoader.php b/src/Loader/MoLoader.php index fe3e09a7..c5079048 100644 --- a/src/Loader/MoLoader.php +++ b/src/Loader/MoLoader.php @@ -4,7 +4,6 @@ namespace Gettext\Loader; use Exception; -use Gettext\Translation; use Gettext\Translations; /** diff --git a/src/Loader/StrictPoLoader.php b/src/Loader/StrictPoLoader.php new file mode 100644 index 00000000..fd80b4f8 --- /dev/null +++ b/src/Loader/StrictPoLoader.php @@ -0,0 +1,485 @@ +data = $data; + $this->position = 0; + $this->translations = parent::loadString($this->data, $translations); + $this->header = $this->translations->find(null, ''); + $this->pluralCount = $this->translations->getHeaders()->getPluralForm()[0] ?? null; + $this->warnings = []; + for ($length = strlen($this->data); $this->newEntry(); $this->saveEntry()) { + for ($hasComment = false; $this->readComment(); $hasComment = true); + $this->readWhitespace(); + // End of data + if ($this->position >= $length) { + if ($hasComment) { + $this->addWarning("Comment ignored at the end of the string{$this->getErrorPosition()}"); + } + break; + } + $this->readContext(); + $this->readOriginal(); + if ($this->translations->has($this->translation)) { + throw new Exception("Duplicated entry{$this->getErrorPosition()}"); + } + if (!$this->readPlural()) { + $this->readTranslation(); + continue; + } + for ($count = 0; $this->readPluralTranslation(!$count); ++$count); + $count !== ($this->pluralCount ?? $count) && $this->addWarning("The translation has {$count} plural " + . "forms, while the header expects {$this->pluralCount}{$this->getErrorPosition()}"); + } + if (!$this->header) { + $this->addWarning("The loaded string has no header translation{$this->getErrorPosition()}"); + } + + return $this->translations; + } + + /** + * Retrieves the collected warnings + * @return string[] + */ + public function getWarnings(): array + { + return $this->warnings; + } + + /** + * Prepares to parse a new translation + */ + private function newEntry(): Translation + { + $this->isDisabled = false; + + return $this->translation = $this->createTranslation(null, ''); + } + + /** + * Adds the current translation to the output list + */ + private function saveEntry(): void + { + if ($this->isHeader()) { + $this->processHeader(); + + return; + } + $this->translations->add($this->translation); + } + + /** + * Attempts to read whitespace characters, also might skip complex comment prologs when needed + * @return int The position before comments started being consumed + */ + private function readWhitespace(): int + { + do { + $this->position += strspn($this->data, " \n\r\t\v\0", $this->position); + $checkpoint ?? $checkpoint = $this->position; + } while (($this->isDisabled && $this->readString('#~')) || ($this->inPreviousPart && $this->readString('#|'))); + + return $checkpoint; + } + + /** + * Attempts to read the exact informed string + */ + private function readString(string $data): bool + { + return !substr_compare($this->data, $data, $this->position, $l = strlen($data)) && $this->position += $l; + } + + /** + * Attempts to read the exact informed char + */ + private function readChar(string $char): bool + { + return ($this->data[$this->position] ?? null) === $char && ++$this->position; + } + + /** + * Read sequential characters that match the given character set until the length range is satisfied + */ + private function readCharset(string $charset, int $min, int $max, string $name): string + { + if (($length = strspn($this->data, $charset, $this->position, $max)) < $min) { + throw new Exception("Expected at least {$min} occurrence of {$name} characters{$this->getErrorPosition()}"); + } + + return substr($this->data, ($this->position += $length) - $length, $length); + } + + /** + * Attempts to read a standard comment string which ends with a newline + */ + private function readCommentString(): string + { + $length = strcspn($this->data, "\n\r", $this->position); + + return substr($this->data, ($this->position += $length) - $length, $length); + } + + /** + * Attempts to read a quoted string while parsing escape sequences prefixed by \ + */ + private function readQuotedString(?string $context = null): string + { + $this->readWhitespace(); + for ($data = '', $isNewPart = true, $checkpoint = null;;) { + if ($isNewPart && !$this->readChar('"')) { + // The data is over (e.g. beginning of an identifier) or perhaps there's an error + // Restore the checkpoint and let the next parser handle it + if ($checkpoint !== null) { + $this->position = $checkpoint; + break; + } + throw new Exception("Expected an opening quote{$this->getErrorPosition()}"); + } + $isNewPart = false; + // Collects chars until an edge case is found + $length = strcspn($this->data, "\"\r\n\\", $this->position); + $data .= substr($this->data, $this->position, $length); + $this->position += $length; + // Check edge cases + switch ($this->data[$this->position++] ?? null) { + // End of part, saves a checkpoint and attempts to read a new part + case '"': + $checkpoint = $this->readWhitespace(); + $isNewPart = true; + break; + case '\\': + $data .= $this->readEscape(); + break; + // Unexpected newline + case "\r": + case "\n": + throw new Exception("Newline character must be escaped{$this->getErrorPosition()}"); + // Unexpected end of file + case null: + throw new Exception("Expected a closing quote{$this->getErrorPosition()}"); + } + } + if ($context && strlen($data) && strpbrk($data[0] . $data[strlen($data) - 1], "\r\n") && !$this->isHeader()) { + $this->addWarning("$context cannot start nor end with a newline{$this->getErrorPosition()}"); + } + + return $data; + } + + /** + * Reads escaped data + */ + private function readEscape(): string + { + $aliasMap = ['from' => 'efnrtv"ab\\', 'to' => "\e\f\n\r\t\v\"\x07\x08\\"]; + $hexDigits = '0123456789abcdefABCDEF'; + switch ($char = $this->data[$this->position++] ?? "\0") { + case strpbrk($char, $aliasMap['from']) ?: '': + return $aliasMap['to'][strpos($aliasMap['from'], $char)]; + case strpbrk($char, $octalDigits = '01234567'): + // GNU gettext fails with an octal above the signed char range + if (($decimal = octdec($char . $this->readCharset($octalDigits, 0, 2, 'octal'))) > 127) { + throw new Exception("Octal value out of range [0, 0177]{$this->getErrorPosition()}"); + } + + return chr($decimal); + case 'x': + $value = $this->readCharset($hexDigits, 1, PHP_INT_MAX, 'hexadecimal'); + // GNU reads all valid hexadecimal chars, but only uses the last pair + return hex2bin(str_pad(substr($value, -2), 2, '0', STR_PAD_LEFT)); + case 'U': + case 'u': + // The GNU gettext is supposed to follow the escaping sequences of C + // Curiously it doesn't support the unicode escape + $value = $this->readCharset($hexDigits, 1, $digits = $char === 'u' ? 4 : 8, 'hexadecimal'); + $value = str_pad($value, $digits, '0', STR_PAD_LEFT); + + return mb_convert_encoding(hex2bin($value), 'UTF-8', 'UTF-' . ($digits * 4)); + } + throw new Exception("Invalid escaped character{$this->getErrorPosition()}"); + } + + /** + * Attempts to read and interpret a comment + */ + private function readComment(): bool + { + $this->readWhitespace(); + if (!$this->readChar('#')) { + return false; + } + $type = strpbrk($this->data[$this->position] ?? '', '~|,:.') ?: ''; + $this->position += strlen($type); + // Only a single space might be optionally added + $this->readChar(' '); + switch ($type) { + case '': + $data = $this->readCommentString(); + $this->translation->getComments()->add($data); + break; + case '~': + if ($this->translation->getPreviousOriginal() !== null) { + throw new Exception("Inconsistent use of #~{$this->getErrorPosition()}"); + } + $this->translation->disable(); + $this->isDisabled = true; + break; + case '|': + if ($this->translation->getPreviousOriginal() !== null) { + throw new Exception('Cannot redeclare the previous comment #|, ' + . "ensure the definitions are in the right order{$this->getErrorPosition()}"); + } + $this->inPreviousPart = true; + $this->translation->setPreviousContext($this->readIdentifier('msgctxt')); + $this->translation->setPreviousOriginal($this->readIdentifier('msgid', true)); + $this->translation->setPreviousPlural($this->readIdentifier('msgid_plural')); + $this->inPreviousPart = false; + break; + case ',': + $data = $this->readCommentString(); + foreach (array_map('trim', explode(',', trim($data))) as $value) { + $this->translation->getFlags()->add($value); + } + break; + case ':': + $data = $this->readCommentString(); + foreach (preg_split('/\s+/', trim($data)) as $value) { + if (preg_match('/^(.+)(:(\d*))?$/U', $value, $matches)) { + $line = isset($matches[3]) ? intval($matches[3]) : null; + $this->translation->getReferences()->add($matches[1], $line); + } + } + break; + case '.': + $data = $this->readCommentString(); + $this->translation->getExtractedComments()->add($data); + break; + } + + return true; + } + + /** + * Attempts to read an identifier + */ + private function readIdentifier(string $identifier, bool $throwIfNotFound = false): ?string + { + $checkpoint = $this->readWhitespace(); + if ($this->readString($identifier)) { + return $this->readQuotedString($identifier); + } + if ($throwIfNotFound) { + throw new Exception("Expected $identifier{$this->getErrorPosition()}"); + } + $this->position = $checkpoint; + + return null; + } + + /** + * Attempts to read the context + */ + private function readContext(): bool + { + return ($data = $this->readIdentifier('msgctxt')) !== null + && ($this->translation = $this->translation->withContext($data)); + } + + /** + * Reads the original message + */ + private function readOriginal(): void + { + $this->translation = $this->translation->withOriginal($this->readIdentifier('msgid', true)); + } + + /** + * Attempts to read the plural message + */ + private function readPlural(): bool + { + return ($data = $this->readIdentifier('msgid_plural')) !== null && $this->translation->setPlural($data); + } + + /** + * Reads the translation + */ + private function readTranslation(): void + { + $this->readWhitespace(); + if (!$this->readString('msgstr')) { + throw new Exception("Expected msgstr{$this->getErrorPosition()}"); + } + $this->translation->translate($this->readQuotedString('msgstr')); + } + + /** + * Attempts to read the pluralized translation + */ + private function readPluralTranslation(bool $throwIfNotFound = false): bool + { + $this->readWhitespace(); + if (!$this->readString('msgstr')) { + if ($throwIfNotFound) { + throw new Exception("Expected indexed msgstr{$this->getErrorPosition()}"); + } + + return false; + } + $this->readWhitespace(); + if (!$this->readChar('[')) { + throw new Exception("Expected character \"[\"{$this->getErrorPosition()}"); + } + $this->readWhitespace(); + $index = (int) $this->readCharset('0123456789', 1, PHP_INT_MAX, 'numeric'); + $this->readWhitespace(); + if (!$this->readChar(']')) { + throw new Exception("Expected character \"]\"{$this->getErrorPosition()}"); + } + $translations = $this->translation->getPluralTranslations(); + if (($translation = $this->translation->getTranslation()) !== null) { + array_unshift($translations, $translation); + } + if (count($translations) !== (int) $index) { + throw new Exception("The msgstr has an invalid index{$this->getErrorPosition()}"); + } + $data = $this->readQuotedString('msgstr'); + $translations[] = $data; + $this->translation->translate(array_shift($translations)); + $this->translation->translatePlural(...$translations); + + return true; + } + + /** + * Setup the current translation as the header translation + */ + private function processHeader(): void + { + $this->header = $header = $this->translation; + if (count($description = $header->getComments()->toArray())) { + $this->translations->setDescription(implode("\n", $description)); + } + if (count($flags = $header->getFlags()->toArray())) { + $this->translations->getFlags()->add(...$flags); + } + $headers = $this->translations->getHeaders(); + if (($header->getTranslation() ?? '') !== '') { + foreach (self::readHeaders($header->getTranslation()) as $name => $value) { + $headers->set($name, $value); + } + } + $this->pluralCount = $headers->getPluralForm()[0] ?? null; + foreach (['Language', 'Plural-Forms', 'Content-Type'] as $header) { + if (($headers->get($header) ?? '') === '') { + $this->addWarning("$header header not declared or empty{$this->getErrorPosition()}"); + } + } + } + + /** + * Parses the translation header data into an array + */ + private function readHeaders(string $data): array + { + $headers = []; + $name = null; + foreach (explode("\n", $data) as $line) { + // Checks if it is a header definition line. + // Useful for distinguishing between header definitions and possible continuations of a header entry. + if (preg_match('/^[\w-]+:/', $line)) { + [$name, $value] = explode(':', $line, 2); + if (isset($headers[$name])) { + $this->addWarning("Header already defined{$this->getErrorPosition()}"); + } + $headers[$name] = trim($value); + continue; + } + // Data without a definition + if ($name === null) { + $this->addWarning("Malformed header name{$this->getErrorPosition()}"); + continue; + } + $headers[$name] .= $line; + } + + return $headers; + } + + /** + * Adds a warning + */ + private function addWarning(string $message): void + { + if ($this->throwOnWarning) { + throw new Exception($message); + } + $this->warnings[] = $message; + } + + /** + * Checks whether the current translation is a header translation + */ + private function isHeader(): bool + { + return $this->translation->getOriginal() === '' && $this->translation->getContext() === null; + } + + /** + * Retrieves the position where an error was detected + */ + private function getErrorPosition(): string + { + if ($this->displayErrorLine) { + $pieces = preg_split("/\\r\\n|\\n\\r|\\n|\\r/", substr($this->data, 0, $this->position)); + $line = count($pieces); + $column = strlen(end($pieces)); + return " at line {$line} column {$column}"; + } + return " at byte {$this->position}"; + } +} diff --git a/src/Scanner/CodeScanner.php b/src/Scanner/CodeScanner.php index e854dd7e..d01caf3b 100644 --- a/src/Scanner/CodeScanner.php +++ b/src/Scanner/CodeScanner.php @@ -5,7 +5,6 @@ use Exception; use Gettext\Translation; -use Gettext\Translations; /** * Base class with common functions to scan files with code and get gettext translations. diff --git a/src/Translation.php b/src/Translation.php index 45aa0a61..6f590c42 100644 --- a/src/Translation.php +++ b/src/Translation.php @@ -19,6 +19,9 @@ class Translation protected $flags; protected $comments; protected $extractedComments; + protected $previousContext; + protected $previousOriginal; + protected $previousPlural; public static function create(?string $context, string $original): Translation { @@ -64,6 +67,9 @@ public function toArray(): array 'plural' => $this->plural, 'pluralTranslations' => $this->pluralTranslations, 'disabled' => $this->disabled, + 'previousContext' => $this->previousContext, + 'previousOriginal' => $this->previousOriginal, + 'previousPlural' => $this->previousPlural, 'references' => $this->getReferences()->toArray(), 'flags' => $this->getFlags()->toArray(), 'comments' => $this->getComments()->toArray(), @@ -116,6 +122,42 @@ public function getPlural(): ?string return $this->plural; } + public function setPreviousOriginal(?string $previousOriginal): self + { + $this->previousOriginal = $previousOriginal; + + return $this; + } + + public function getPreviousOriginal(): ?string + { + return $this->previousOriginal; + } + + public function setPreviousContext(?string $previousContext): self + { + $this->previousContext = $previousContext; + + return $this; + } + + public function getPreviousContext(): ?string + { + return $this->previousContext; + } + + public function setPreviousPlural(?string $previousPlural): self + { + $this->previousPlural = $previousPlural; + + return $this; + } + + public function getPreviousPlural(): ?string + { + return $this->previousPlural; + } + public function disable(bool $disabled = true): self { $this->disabled = $disabled; @@ -225,6 +267,18 @@ public function mergeWith(Translation $translation, int $strategy = 0): Translat $merged->plural = $translation->plural; } + if (!$merged->previousContext || ($translation->previousContext && $override)) { + $merged->previousContext = $translation->previousContext; + } + + if (!$merged->previousOriginal || ($translation->previousOriginal && $override)) { + $merged->previousOriginal = $translation->previousOriginal; + } + + if (!$merged->previousPlural || ($translation->previousPlural && $override)) { + $merged->previousPlural = $translation->previousPlural; + } + if (empty($merged->pluralTranslations) || (!empty($translation->pluralTranslations) && $override)) { $merged->pluralTranslations = $translation->pluralTranslations; } diff --git a/src/Translations.php b/src/Translations.php index eed33979..f0a5964f 100644 --- a/src/Translations.php +++ b/src/Translations.php @@ -125,11 +125,7 @@ public function addOrMerge(Translation $translation, int $mergeStrategy = 0): Tr public function remove(Translation $translation): self { - $key = array_search($translation, $this->translations); - - if ($key !== false) { - unset($this->translations[$key]); - } + unset($this->translations[$translation->getId()]); return $this; } @@ -168,13 +164,12 @@ public function getLanguage(): ?string public function find(?string $context, string $original): ?Translation { - foreach ($this->translations as $translation) { - if ($translation->getContext() === $context && $translation->getOriginal() === $original) { - return $translation; - } - } + return $this->translations[(Translation::create($context, $original))->getId()] ?? null; + } - return null; + public function has(Translation $translation): bool + { + return (bool) ($this->translations[$translation->getId()] ?? false); } public function mergeWith(Translations $translations, int $strategy = 0): Translations diff --git a/tests/BasePoLoaderTestCase.php b/tests/BasePoLoaderTestCase.php new file mode 100644 index 00000000..76cdac63 --- /dev/null +++ b/tests/BasePoLoaderTestCase.php @@ -0,0 +1,291 @@ +createPoLoader(); + $translations = $loader->loadFile(__DIR__.'/assets/translations.po'); + + $description = $translations->getDescription(); + $this->assertSame( + <<<'EOT' +SOME DESCRIPTIVE TITLE +Copyright (C) YEAR Free Software Foundation, Inc. +This file is distributed under the same license as the PACKAGE package. +FIRST AUTHOR , YEAR. +EOT + , + $description + ); + + $this->assertSame(['fuzzy'], $translations->getFlags()->toArray()); + + $this->assertCount(14, $translations); + + $array = $translations->getTranslations(); + + $this->translation1(array_shift($array)); + $this->translation2(array_shift($array)); + $this->translation3(array_shift($array)); + $this->translation4(array_shift($array)); + $this->translation5(array_shift($array)); + $this->translation6(array_shift($array)); + $this->translation7(array_shift($array)); + $this->translation8(array_shift($array)); + $this->translation9(array_shift($array)); + $this->translation10(array_shift($array)); + $this->translation11(array_shift($array)); + $this->translation12(array_shift($array)); + $this->translation13(array_shift($array)); + $this->translation14(array_shift($array)); + + $headers = $translations->getHeaders()->toArray(); + + $this->assertCount(12, $headers); + + $this->assertSame('text/plain; charset=UTF-8', $headers['Content-Type']); + $this->assertSame('8bit', $headers['Content-Transfer-Encoding']); + $this->assertSame('', $headers['POT-Creation-Date']); + $this->assertSame('', $headers['PO-Revision-Date']); + $this->assertSame('', $headers['Last-Translator']); + $this->assertSame('', $headers['Language-Team']); + $this->assertSame('1.0', $headers['MIME-Version']); + $this->assertSame('bs', $headers['Language']); + $this->assertSame( + 'nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);', + $headers['Plural-Forms'] + ); + $this->assertSame('Poedit 1.6.5', $headers['X-Generator']); + $this->assertSame('gettext generator test', $headers['Project-Id-Version']); + $this->assertSame('testingdomain', $headers['X-Domain']); + + $this->assertSame('testingdomain', $translations->getDomain()); + $this->assertSame('bs', $translations->getLanguage()); + } + + private function translation1(Translation $translation) + { + $this->assertSame( + 'Ensure this value has at least %(limit_value)d character (it has %sd).', + $translation->getOriginal() + ); + $this->assertSame( + 'Ensure this value has at least %(limit_value)d characters (it has %sd).', + $translation->getPlural() + ); + $this->assertSame('', $translation->getTranslation()); + $this->assertSame(['', ''], $translation->getPluralTranslations()); + } + + private function translation2(Translation $translation) + { + $this->assertSame( + 'Ensure this value has at most %(limit_value)d character (it has %sd).', + $translation->getOriginal() + ); + $this->assertSame( + 'Ensure this value has at most %(limit_value)d characters (it has %sd).', + $translation->getPlural() + ); + $this->assertSame('', $translation->getTranslation()); + $this->assertSame(['', ''], $translation->getPluralTranslations()); + } + + private function translation3(Translation $translation) + { + $this->assertSame('%ss must be unique for %ss %ss.', $translation->getOriginal()); + $this->assertNull($translation->getPlural()); + $this->assertSame('%ss mora da bude jedinstven za %ss %ss.', $translation->getTranslation()); + $this->assertCount(0, $translation->getPluralTranslations()); + } + + private function translation4(Translation $translation) + { + $this->assertSame('and', $translation->getOriginal()); + $this->assertNull($translation->getPlural()); + $this->assertSame('i', $translation->getTranslation()); + $this->assertCount(0, $translation->getPluralTranslations()); + $this->assertSame(['c-format'], $translation->getFlags()->toArray()); + } + + private function translation5(Translation $translation) + { + $this->assertSame('Value %sr is not a valid choice.', $translation->getOriginal()); + $this->assertNull($translation->getPlural()); + $this->assertSame('', $translation->getTranslation()); + $this->assertCount(0, $translation->getPluralTranslations()); + $this->assertSame(['This is a extracted comment'], $translation->getExtractedComments()->toArray()); + } + + private function translation6(Translation $translation) + { + $this->assertSame('This field cannot be null.', $translation->getOriginal()); + $this->assertNull($translation->getPlural()); + $this->assertSame('Ovo polje ne može ostati prazno.', $translation->getTranslation()); + $this->assertCount(0, $translation->getPluralTranslations()); + $this->assertCount(1, $translation->getReferences()); + $this->assertSame(['C:/Users/Me/Documents/foo2.php' => [1]], $translation->getReferences()->toArray()); + } + + private function translation7(Translation $translation) + { + $this->assertSame('This field cannot be blank.', $translation->getOriginal()); + $this->assertNull($translation->getPlural()); + $this->assertSame('Ovo polje ne može biti prazno.', $translation->getTranslation()); + $this->assertCount(0, $translation->getPluralTranslations()); + $this->assertCount(1, $translation->getReferences()); + $this->assertSame(['C:/Users/Me/Documents/foo1.php' => []], $translation->getReferences()->toArray()); + } + + private function translation8(Translation $translation) + { + $this->assertSame('Field of type: %ss', $translation->getOriginal()); + $this->assertNull($translation->getPlural()); + $this->assertSame('Polje tipa: %ss', $translation->getTranslation()); + $this->assertCount(0, $translation->getPluralTranslations()); + $this->assertCount(2, $translation->getReferences()); + $this->assertSame( + [ + 'attributes/address/composer.php' => [8], + 'attributes/address/form.php' => [7], + ], + $translation->getReferences()->toArray() + ); + } + + private function translation9(Translation $translation) + { + $this->assertSame('Integer', $translation->getOriginal()); + $this->assertNull($translation->getPlural()); + $this->assertSame('Cijeo broj', $translation->getTranslation()); + $this->assertCount(0, $translation->getPluralTranslations()); + $this->assertCount(0, $translation->getReferences()); + $this->assertCount(1, $translation->getComments()); + $this->assertSame(['a simple line comment is above'], $translation->getComments()->toArray()); + } + + private function translation10(Translation $translation) + { + $this->assertSame('{test1}', $translation->getOriginal()); + $this->assertNull($translation->getPlural()); + $this->assertSame("test1\n
\n test2\n
\ntest3", $translation->getTranslation()); + $this->assertCount(0, $translation->getPluralTranslations()); + $this->assertCount(0, $translation->getComments()); + $this->assertCount(3, $translation->getReferences()); + $this->assertSame( + [ + '/var/www/test/test.php' => [96, 97], + '/var/www/test/test2.php' => [98], + ], + $translation->getReferences()->toArray() + ); + } + + private function translation11(Translation $translation) + { + $this->assertSame('{test2}', $translation->getOriginal()); + $this->assertNull($translation->getPlural()); + $this->assertSame("test1\n
\n test2\n
\ntest3", $translation->getTranslation()); + $this->assertCount(0, $translation->getPluralTranslations()); + $this->assertCount(0, $translation->getComments()); + $this->assertCount(1, $translation->getReferences()); + $this->assertSame( + ['/var/www/test/test.php' => [96]], + $translation->getReferences()->toArray() + ); + } + + private function translation12(Translation $translation) + { + $this->assertSame('Multibyte test', $translation->getOriginal()); + $this->assertNull($translation->getPlural()); + $this->assertSame('日本人は日本で話される言語です!', $translation->getTranslation()); + $this->assertCount(0, $translation->getPluralTranslations()); + $this->assertCount(0, $translation->getComments()); + $this->assertCount(0, $translation->getReferences()); + } + + private function translation13(Translation $translation) + { + $this->assertSame('Tabulation test', $translation->getOriginal()); + $this->assertNull($translation->getPlural()); + $this->assertSame("FIELD\tFIELD", $translation->getTranslation()); + $this->assertCount(0, $translation->getPluralTranslations()); + $this->assertCount(0, $translation->getComments()); + $this->assertCount(0, $translation->getReferences()); + } + + private function translation14(Translation $translation) + { + $this->assertSame('%s has been added to your cart.', $translation->getOriginal()); + $this->assertSame('%s have been added to your cart.', $translation->getPlural()); + $this->assertSame('%s has been added to your cart.', $translation->getTranslation()); + $this->assertSame(['%s have been added to your cart.'], $translation->getPluralTranslations()); + $this->assertCount(1, $translation->getComments()); + $this->assertCount(0, $translation->getReferences()); + } + + public function stringDecodeProvider() + { + return [ + ['"test"', 'test'], + ['"\'test\'"', "'test'"], + ['"Special chars: \\n \\t \\\\ "', "Special chars: \n \t \\ "], + ['"Newline\nSlash and n\\\\nend"', "Newline\nSlash and n\\nend"], + ['"Quoted \\"string\\" with %s"', 'Quoted "string" with %s'], + ['"\\\\x07 - aka \\\\a: \\a"', "\\x07 - aka \\a: \x07"], + ['"\\\\x08 - aka \\\\b: \\b"', "\\x08 - aka \\b: \x08"], + ['"\\\\x09 - aka \\\\t: \\t"', "\\x09 - aka \\t: \t"], + ['"\\\\x0a - aka \\\\n: \\n "', "\\x0a - aka \\n: \n "], + ['"\\\\x0b - aka \\\\v: \\v"', "\\x0b - aka \\v: \x0b"], + ['"\\\\x0c - aka \\\\f: \\f"', "\\x0c - aka \\f: \x0c"], + ['"\\\\x0d - aka \\\\r: \\r "', "\\x0d - aka \\r: \r "], + ['"\\\\x22 - aka \\": \\""', '\x22 - aka ": "'], + ['"\\\\x5c - aka \\\\: \\\\"', '\\x5c - aka \\: \\'], + ]; + } + + /** + * @dataProvider stringDecodeProvider + * @param mixed $source + * @param mixed $decoded + */ + public function testStringDecode($source, $decoded) + { + $po = <<createPoLoader()->loadString($po); + $this->assertSame($decoded, $translations->find(null, 'source')->getTranslation()); + } + + public function testMultilineDisabledTranslations() + { + $po = <<<'EOT' +#~ msgid "Last agent hours-description" +#~ msgstr "" +#~ "How many hours in the past can system look at finding the last agent? " +#~ "This parameter is only used if 'Call Last Agent' is set to 'YES'." +EOT; + $loader = $this->createPoLoader(); + $translations = $loader->loadString($po); + $translation = $translations->find(null, 'Last agent hours-description'); + + $this->assertTrue($translation->isDisabled()); + $this->assertEquals( + "How many hours in the past can system look at finding the last agent? This parameter is only used if 'Call Last Agent' is set to 'YES'.", + $translation->getTranslation() + ); + } +} diff --git a/tests/PoLoaderTest.php b/tests/PoLoaderTest.php index cfe4729f..3708690e 100644 --- a/tests/PoLoaderTest.php +++ b/tests/PoLoaderTest.php @@ -3,254 +3,14 @@ namespace Gettext\Tests; +use Gettext\Loader\Loader; use Gettext\Loader\PoLoader; -use Gettext\Translation; -use PHPUnit\Framework\TestCase; -class PoLoaderTest extends TestCase +class PoLoaderTest extends BasePoLoaderTestCase { - public function testPoLoader() + protected function createPoLoader(): Loader { - $loader = new PoLoader(); - $translations = $loader->loadFile(__DIR__.'/assets/translations.po'); - - $description = $translations->getDescription(); - $this->assertSame( - <<<'EOT' -SOME DESCRIPTIVE TITLE -Copyright (C) YEAR Free Software Foundation, Inc. -This file is distributed under the same license as the PACKAGE package. -FIRST AUTHOR , YEAR. -EOT - , - $description - ); - - $this->assertSame(['fuzzy'], $translations->getFlags()->toArray()); - - $this->assertCount(14, $translations); - - $array = $translations->getTranslations(); - - $this->translation1(array_shift($array)); - $this->translation2(array_shift($array)); - $this->translation3(array_shift($array)); - $this->translation4(array_shift($array)); - $this->translation5(array_shift($array)); - $this->translation6(array_shift($array)); - $this->translation7(array_shift($array)); - $this->translation8(array_shift($array)); - $this->translation9(array_shift($array)); - $this->translation10(array_shift($array)); - $this->translation11(array_shift($array)); - $this->translation12(array_shift($array)); - $this->translation13(array_shift($array)); - $this->translation14(array_shift($array)); - - $headers = $translations->getHeaders()->toArray(); - - $this->assertCount(12, $headers); - - $this->assertSame('text/plain; charset=UTF-8', $headers['Content-Type']); - $this->assertSame('8bit', $headers['Content-Transfer-Encoding']); - $this->assertSame('', $headers['POT-Creation-Date']); - $this->assertSame('', $headers['PO-Revision-Date']); - $this->assertSame('', $headers['Last-Translator']); - $this->assertSame('', $headers['Language-Team']); - $this->assertSame('1.0', $headers['MIME-Version']); - $this->assertSame('bs', $headers['Language']); - $this->assertSame( - 'nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);', - $headers['Plural-Forms'] - ); - $this->assertSame('Poedit 1.6.5', $headers['X-Generator']); - $this->assertSame('gettext generator test', $headers['Project-Id-Version']); - $this->assertSame('testingdomain', $headers['X-Domain']); - - $this->assertSame('testingdomain', $translations->getDomain()); - $this->assertSame('bs', $translations->getLanguage()); - } - - private function translation1(Translation $translation) - { - $this->assertSame( - 'Ensure this value has at least %(limit_value)d character (it has %sd).', - $translation->getOriginal() - ); - $this->assertSame( - 'Ensure this value has at least %(limit_value)d characters (it has %sd).', - $translation->getPlural() - ); - $this->assertSame('', $translation->getTranslation()); - $this->assertSame(['', ''], $translation->getPluralTranslations()); - } - - private function translation2(Translation $translation) - { - $this->assertSame( - 'Ensure this value has at most %(limit_value)d character (it has %sd).', - $translation->getOriginal() - ); - $this->assertSame( - 'Ensure this value has at most %(limit_value)d characters (it has %sd).', - $translation->getPlural() - ); - $this->assertSame('', $translation->getTranslation()); - $this->assertSame(['', ''], $translation->getPluralTranslations()); - } - - private function translation3(Translation $translation) - { - $this->assertSame('%ss must be unique for %ss %ss.', $translation->getOriginal()); - $this->assertNull($translation->getPlural()); - $this->assertSame('%ss mora da bude jedinstven za %ss %ss.', $translation->getTranslation()); - $this->assertCount(0, $translation->getPluralTranslations()); - } - - private function translation4(Translation $translation) - { - $this->assertSame('and', $translation->getOriginal()); - $this->assertNull($translation->getPlural()); - $this->assertSame('i', $translation->getTranslation()); - $this->assertCount(0, $translation->getPluralTranslations()); - $this->assertSame(['c-format'], $translation->getFlags()->toArray()); - } - - private function translation5(Translation $translation) - { - $this->assertSame('Value %sr is not a valid choice.', $translation->getOriginal()); - $this->assertNull($translation->getPlural()); - $this->assertSame('', $translation->getTranslation()); - $this->assertCount(0, $translation->getPluralTranslations()); - $this->assertSame(['This is a extracted comment'], $translation->getExtractedComments()->toArray()); - } - - private function translation6(Translation $translation) - { - $this->assertSame('This field cannot be null.', $translation->getOriginal()); - $this->assertNull($translation->getPlural()); - $this->assertSame('Ovo polje ne može ostati prazno.', $translation->getTranslation()); - $this->assertCount(0, $translation->getPluralTranslations()); - $this->assertCount(1, $translation->getReferences()); - $this->assertSame(['C:/Users/Me/Documents/foo2.php' => [1]], $translation->getReferences()->toArray()); - } - - private function translation7(Translation $translation) - { - $this->assertSame('This field cannot be blank.', $translation->getOriginal()); - $this->assertNull($translation->getPlural()); - $this->assertSame('Ovo polje ne može biti prazno.', $translation->getTranslation()); - $this->assertCount(0, $translation->getPluralTranslations()); - $this->assertCount(1, $translation->getReferences()); - $this->assertSame(['C:/Users/Me/Documents/foo1.php' => []], $translation->getReferences()->toArray()); - } - - private function translation8(Translation $translation) - { - $this->assertSame('Field of type: %ss', $translation->getOriginal()); - $this->assertNull($translation->getPlural()); - $this->assertSame('Polje tipa: %ss', $translation->getTranslation()); - $this->assertCount(0, $translation->getPluralTranslations()); - $this->assertCount(2, $translation->getReferences()); - $this->assertSame( - [ - 'attributes/address/composer.php' => [8], - 'attributes/address/form.php' => [7], - ], - $translation->getReferences()->toArray() - ); - } - - private function translation9(Translation $translation) - { - $this->assertSame('Integer', $translation->getOriginal()); - $this->assertNull($translation->getPlural()); - $this->assertSame('Cijeo broj', $translation->getTranslation()); - $this->assertCount(0, $translation->getPluralTranslations()); - $this->assertCount(0, $translation->getReferences()); - $this->assertCount(1, $translation->getComments()); - $this->assertSame(['a simple line comment is above'], $translation->getComments()->toArray()); - } - - private function translation10(Translation $translation) - { - $this->assertSame('{test1}', $translation->getOriginal()); - $this->assertNull($translation->getPlural()); - $this->assertSame("test1\n
\n test2\n
\ntest3", $translation->getTranslation()); - $this->assertCount(0, $translation->getPluralTranslations()); - $this->assertCount(0, $translation->getComments()); - $this->assertCount(3, $translation->getReferences()); - $this->assertSame( - [ - '/var/www/test/test.php' => [96, 97], - '/var/www/test/test2.php' => [98], - ], - $translation->getReferences()->toArray() - ); - } - - private function translation11(Translation $translation) - { - $this->assertSame('{test2}', $translation->getOriginal()); - $this->assertNull($translation->getPlural()); - $this->assertSame("test1\n
\n test2\n
\ntest3", $translation->getTranslation()); - $this->assertCount(0, $translation->getPluralTranslations()); - $this->assertCount(0, $translation->getComments()); - $this->assertCount(1, $translation->getReferences()); - $this->assertSame( - ['/var/www/test/test.php' => [96]], - $translation->getReferences()->toArray() - ); - } - - private function translation12(Translation $translation) - { - $this->assertSame('Multibyte test', $translation->getOriginal()); - $this->assertNull($translation->getPlural()); - $this->assertSame('日本人は日本で話される言語です!', $translation->getTranslation()); - $this->assertCount(0, $translation->getPluralTranslations()); - $this->assertCount(0, $translation->getComments()); - $this->assertCount(0, $translation->getReferences()); - } - - private function translation13(Translation $translation) - { - $this->assertSame('Tabulation test', $translation->getOriginal()); - $this->assertNull($translation->getPlural()); - $this->assertSame("FIELD\tFIELD", $translation->getTranslation()); - $this->assertCount(0, $translation->getPluralTranslations()); - $this->assertCount(0, $translation->getComments()); - $this->assertCount(0, $translation->getReferences()); - } - - private function translation14(Translation $translation) - { - $this->assertSame('%s has been added to your cart.', $translation->getOriginal()); - $this->assertSame('%s have been added to your cart.', $translation->getPlural()); - $this->assertSame('%s has been added to your cart.', $translation->getTranslation()); - $this->assertSame(['%s have been added to your cart.'], $translation->getPluralTranslations()); - $this->assertCount(1, $translation->getComments()); - $this->assertCount(0, $translation->getReferences()); - } - - public function stringDecodeProvider() - { - return [ - ['"test"', 'test'], - ['"\'test\'"', "'test'"], - ['"Special chars: \\n \\t \\\\ "', "Special chars: \n \t \\ "], - ['"Newline\nSlash and n\\\\nend"', "Newline\nSlash and n\\nend"], - ['"Quoted \\"string\\" with %s"', 'Quoted "string" with %s'], - ['"\\\\x07 - aka \\\\a: \\a"', "\\x07 - aka \\a: \x07"], - ['"\\\\x08 - aka \\\\b: \\b"', "\\x08 - aka \\b: \x08"], - ['"\\\\x09 - aka \\\\t: \\t"', "\\x09 - aka \\t: \t"], - ['"\\\\x0a - aka \\\\n: \\n"', "\\x0a - aka \\n: \n"], - ['"\\\\x0b - aka \\\\v: \\v"', "\\x0b - aka \\v: \x0b"], - ['"\\\\x0c - aka \\\\f: \\f"', "\\x0c - aka \\f: \x0c"], - ['"\\\\x0d - aka \\\\r: \\r"', "\\x0d - aka \\r: \r"], - ['"\\\\x22 - aka \\": \\""', '\x22 - aka ": "'], - ['"\\\\x5c - aka \\\\: \\\\"', '\\x5c - aka \\: \\'], - ]; + return new PoLoader(); } /** @@ -262,23 +22,4 @@ public function testStringDecode($source, $decoded) { $this->assertSame($decoded, PoLoader::decode($source)); } - - public function testMultilineDisabledTranslations() - { - $po = <<<'EOT' -#~ msgid "Last agent hours-description" -#~ msgstr "" -#~ "How many hours in the past can system look at finding the last agent? " -#~ "This parameter is only used if 'Call Last Agent' is set to 'YES'." -EOT; - $loader = new PoLoader(); - $translations = $loader->loadString($po); - $translation = $translations->find(null, 'Last agent hours-description'); - - $this->assertTrue($translation->isDisabled()); - $this->assertEquals( - "How many hours in the past can system look at finding the last agent? This parameter is only used if 'Call Last Agent' is set to 'YES'.", - $translation->getTranslation() - ); - } } diff --git a/tests/StrictPoLoaderTest.php b/tests/StrictPoLoaderTest.php new file mode 100644 index 00000000..379e8b0c --- /dev/null +++ b/tests/StrictPoLoaderTest.php @@ -0,0 +1,305 @@ +createPoLoader()->loadString($po); + $this->assertEquals($translations->find('ctx', 'original')->getTranslation(), 'translation'); + $this->assertEquals($translations->find('ctx', 'original')->getComments()->toArray()[0], ' comment'); + } + + public function testPreviousTranslation(): void + { + $po = '#| msgctxt "previous ctx" + #| msgid "previous original" + #| msgid_plural "previous plural" + msgctxt "ctx" + msgid "original" + msgid_plural "plural" + msgstr[0] "translation"'; + $translations = $this->createPoLoader()->loadString($po); + + $translation = $translations->find('ctx', 'original'); + $this->assertNotNull($translation); + $this->assertEquals($translation->getContext(), 'ctx'); + $this->assertEquals($translation->getOriginal(), 'original'); + $this->assertEquals($translation->getPlural(), 'plural'); + $this->assertEquals($translation->getTranslation(), 'translation'); + + $this->assertEquals($translation->getPreviousContext(), 'previous ctx'); + $this->assertEquals($translation->getPreviousOriginal(), 'previous original'); + $this->assertEquals($translation->getPreviousPlural(), 'previous plural'); + } + + public function testDisabledWithPreviousTranslation(): void + { + $po = '#~ #| msgctxt "previous ctx" + #~ #| msgid "previous original" + #~ #| msgid_plural "previous plural" + #~ msgctxt "ctx" + #~ msgid "original" + #~ msgid_plural "plural" + #~ msgstr[0] "translation"'; + $translations = $this->createPoLoader()->loadString($po); + + $translation = $translations->find('ctx', 'original'); + $this->assertNotNull($translation); + $this->assertTrue($translation->isDisabled()); + $this->assertEquals($translation->getContext(), 'ctx'); + $this->assertEquals($translation->getOriginal(), 'original'); + $this->assertEquals($translation->getPlural(), 'plural'); + $this->assertEquals($translation->getTranslation(), 'translation'); + + $this->assertEquals($translation->getPreviousContext(), 'previous ctx'); + $this->assertEquals($translation->getPreviousOriginal(), 'previous original'); + $this->assertEquals($translation->getPreviousPlural(), 'previous plural'); + } + + public function badFormattedPoProvider(): array + { + return [ + 'Duplicated entry' => [ + '/Duplicated entry/', + 'msgid"original" + msgstr"translation" + + msgid"original" + msgstr"translation 2"', + ], + 'Out of order: msgstr before msgid' => [ + '/Expected msgid/', + 'msgstr "translation" + msgid "original"', + ], + 'Out of order: msgctxt before msgid' => [ + '/Expected msgid/', + 'msgctxt "ctx" + msgstr "translation" + msgid "original"', + ], + 'Out of order: Comment between the definitions' => [ + '/Expected msgstr/', + 'msgid "original" + # Unexpected comment + msgstr "translation"', + ], + 'Out of order: Disabled translations (#~) cannot appear after previous translations (#|)' => [ + '/Inconsistent use of #~/', + '#|msgid "previous" + #~msgid "disabled" + #~msgstr "disabled translation" + msgid "original" + msgstr "translation"', + ], + 'Out of order: msgctxt of a previous translation (#|) must appear before its msgid' => [ + '/Cannot redeclare the previous comment/', + '#|msgid "previous" + #|msgctxt "previous context" + #|msgid_plural "previous context" + msgid "original" + msgstr "translation"', + ], + 'Indexed msgstr: msgid_plural requires an indexed msgstr' => [ + '/Expected character "\\["/', + 'msgid "original" + msgid_plural "plural" + msgstr "translation"', + ], + 'Indexed msgstr: After the index 0, the next should be 2' => [ + '/The msgstr has an invalid index/', + 'msgid "original" + msgid_plural "plural" + msgstr[0] "translation" + msgstr[2] "translation"', + ], + 'Indexed msgstr: Index has trash data (whitespace is ok)' => [ + '/Expected character "]"/', + 'msgid "original" + msgid_plural "plural" + msgstr[ 0 ] "translation" + msgstr[1s] "translation"', + ], + 'Incomplete translation' => [ + '/Expected msgstr/', + 'msgid "original"', + ], + 'Incomplete disabled translation' => [ + '/Expected msgstr/', + '#~ msgid "original"', + ], + 'Encoding: No quotes' => [ + '/Expected an opening quote/', + 'msgid "original" + msgstr translation', + ], + 'Encoding: Missing opening quote' => [ + '/Expected an opening quote/', + 'msgid "original" + msgstr translation"', + ], + 'Encoding: Missing closing quote' => [ + '/Expected a closing quot/', + 'msgid "original" + msgstr "translation', + ], + 'Encoding: Unescaped newline (using \\n)' => [ + '/Newline character must be escaped/', + "msgid \"original\" + msgstr \"trans\nlation\"", + ], + 'Encoding: Unescaped newline (using \\r)' => [ + '/Newline character must be escaped/', + "msgid \"original\" + msgstr \"trans\rlation\"", + ], + 'Encoding: Invalid octal digit' => [ + '/Invalid escaped character/', + 'msgid "original" + msgstr "translation\8"', + ], + 'Encoding: Octal out of range' => [ + '/Octal value out of range/', + 'msgid "original" + msgstr "translation\777"', + ], + 'Encoding: Invalid hexadecimal digit' => [ + '/Expected at least 1 occurrence of hexadecimal/', + 'msgid "original" + msgstr "translation\xGG"', + ], + 'Encoding: Invalid unicode digit' => [ + '/Expected at least 1 occurrence of hexadecimal/', + 'msgid "original" + msgstr "translation\uZZ"', + ], + 'Invalid identifier "unknown"' => [ + '/Expected msgid/', + 'unknown "original" + msgstr "translation"', + ], + // The checks below depends on the $throwOnWarning = true + 'msgid, msgid_plural and msgstr cannot begin nor end with a newline' => [ + '/msgstr cannot start nor end with a newline/', + 'msgid "original" + msgstr "translation\n"', + true, + ], + 'Missing header' => [ + '/The loaded string has no header translation/', + 'msgid "original" + msgstr "translation"', + true, + ], + 'Duplicated header' => [ + '/Header already defined/', + 'msgid "" + msgstr "Header: \\n" + "Header: \\n"', + true, + ], + 'Malformed header name' => [ + '/Malformed header name/', + 'msgid "" + msgstr "Header\\n"', + true, + ], + 'Missing standard headers Language/Plural-Forms/Content-Type' => [ + '/header not declared or empty/', + 'msgid "" + msgstr "Header: Value\\n"', + true, + ], + 'Two plural forms with just one plural translation' => [ + '/The translation has \\d+ plural forms, while the header expects \\d+/', + 'msgid "" + msgstr "Language: en_US\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Plural-Forms: nplurals=2; plural=n != 1;\n" + + msgid "original" + msgid_plural "plural" + msgstr[0] "translation"', + true, + ], + 'Two plural forms with 3 plural translations' => [ + '/The translation has \\d+ plural forms, while the header expects \\d+/', + 'msgid "" + msgstr "Language: en_US\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Plural-Forms: nplurals=2; plural=n != 1;\n" + + msgid "original" + msgid_plural "plural" + msgstr[0] "translation" + msgstr[1] "translation" + msgstr[2] "translation"', + true, + ], + 'Dangling comment in the end of the data' => [ + '/Comment ignored at the end/', + 'msgid "original" + msgstr "translation" + + # Dangling comment', + true, + ], + 'Dangling comment in the end of the data using error report with line/column' => [ + '/Comment ignored at the end.*line 4 column 34/', + 'msgid "original" + msgstr "translation" + + # Dangling comment', + true, + true, + ], + ]; + } + + /** + * @dataProvider badFormattedPoProvider + */ + public function testBadFormattedPo( + string $exceptionPattern, + string $po, + bool $throwOnWarning = false, + bool $displayErrorLine = false + ): void + { + $this->expectExceptionMessageMatches($exceptionPattern); + $loader = $this->createPoLoader(); + $loader->throwOnWarning = $throwOnWarning; + $loader->displayErrorLine = $displayErrorLine; + $loader->loadString($po); + } +} diff --git a/tests/assets/translations.po b/tests/assets/translations.po index b172a8ed..d37fbf0b 100644 --- a/tests/assets/translations.po +++ b/tests/assets/translations.po @@ -2,7 +2,6 @@ # Copyright (C) YEAR Free Software Foundation, Inc. # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# #, fuzzy msgid "" msgstr "" diff --git a/tests/snapshots/testNoStrategy.php b/tests/snapshots/testNoStrategy.php index 0271284e..f26af6aa 100644 --- a/tests/snapshots/testNoStrategy.php +++ b/tests/snapshots/testNoStrategy.php @@ -21,6 +21,9 @@ 'plural' => null, 'pluralTranslations' => [], 'disabled' => false, + 'previousContext' => null, + 'previousOriginal' => null, + 'previousPlural' => null, 'references' => [ 'template.php' => [ 3, @@ -42,6 +45,9 @@ 'plural' => null, 'pluralTranslations' => [], 'disabled' => true, + 'previousContext' => null, + 'previousOriginal' => null, + 'previousPlural' => null, 'references' => [ 'template.php' => [ 4 @@ -63,6 +69,9 @@ '%s comentarios' ], 'disabled' => false, + 'previousContext' => null, + 'previousOriginal' => null, + 'previousPlural' => null, 'references' => [ 'template.php' => [ 5, @@ -83,6 +92,9 @@ 'plural' => null, 'pluralTranslations' => [], 'disabled' => false, + 'previousContext' => null, + 'previousOriginal' => null, + 'previousPlural' => null, 'references' => [ 'template.php' => [ 10 @@ -105,6 +117,9 @@ 'plural' => null, 'pluralTranslations' => [], 'disabled' => false, + 'previousContext' => null, + 'previousOriginal' => null, + 'previousPlural' => null, 'references' => [ 'template.php' => [ 11 @@ -122,6 +137,9 @@ 'plural' => null, 'pluralTranslations' => [], 'disabled' => false, + 'previousContext' => null, + 'previousOriginal' => null, + 'previousPlural' => null, 'references' => [ 'template.php' => [ 2 diff --git a/tests/snapshots/testScanAndLoadStrategy.php b/tests/snapshots/testScanAndLoadStrategy.php index 272bcebc..91e970ad 100644 --- a/tests/snapshots/testScanAndLoadStrategy.php +++ b/tests/snapshots/testScanAndLoadStrategy.php @@ -19,6 +19,9 @@ 'plural' => null, 'pluralTranslations' => [], 'disabled' => false, + 'previousContext' => null, + 'previousOriginal' => null, + 'previousPlural' => null, 'references' => [ 'template.php' => [ 3 @@ -36,6 +39,9 @@ 'plural' => null, 'pluralTranslations' => [], 'disabled' => true, + 'previousContext' => null, + 'previousOriginal' => null, + 'previousPlural' => null, 'references' => [ 'template.php' => [ 4 @@ -57,6 +63,9 @@ '%s comentarios' ], 'disabled' => false, + 'previousContext' => null, + 'previousOriginal' => null, + 'previousPlural' => null, 'references' => [ 'template.php' => [ 5 @@ -76,6 +85,9 @@ 'plural' => null, 'pluralTranslations' => [], 'disabled' => false, + 'previousContext' => null, + 'previousOriginal' => null, + 'previousPlural' => null, 'references' => [ 'template.php' => [ 10 @@ -97,6 +109,9 @@ 'plural' => null, 'pluralTranslations' => [], 'disabled' => false, + 'previousContext' => null, + 'previousOriginal' => null, + 'previousPlural' => null, 'references' => [ 'template.php' => [ 11 diff --git a/tests/snapshots/testUpdateTemplatesEntries.php b/tests/snapshots/testUpdateTemplatesEntries.php deleted file mode 100644 index b53aa030..00000000 --- a/tests/snapshots/testUpdateTemplatesEntries.php +++ /dev/null @@ -1,109 +0,0 @@ - [ - 'Language' => 'gl_ES', - 'Language-Team' => 'My Team', - 'Last-Translator' => 'Oscar', - 'POT-Creation-Date' => '2019-10-10 10:10:10', - 'X-Domain' => 'my-domain', - 'X-Foo' => 'bar', - 'X-Generator' => 'PHP Gettext scanner', - ], - 'translations' => [ - [ - 'id' => 'title', - 'context' => null, - 'original' => 'title', - 'translation' => 'Título', - 'plural' => null, - 'pluralTranslations' => [], - 'disabled' => false, - 'references' => [ - 'template.php' => [ - 3, - ], - ], - 'flags' => [], - 'comments' => [], - 'extractedComments' => [], - ], - [ - 'id' => 'intro', - 'context' => null, - 'original' => 'intro', - 'translation' => 'Intro', - 'plural' => null, - 'pluralTranslations' => [], - 'disabled' => true, - 'references' => [ - 'template.php' => [ - 4, - ], - ], - 'flags' => [], - 'comments' => [ - 'Disabled comment', - ], - 'extractedComments' => [], - ], - [ - 'id' => 'one comment', - 'context' => null, - 'original' => 'one comment', - 'translation' => 'Un comentario', - 'plural' => '%s comments', - 'pluralTranslations' => [ - '%s comentarios', - ], - 'disabled' => false, - 'references' => [ - 'template.php' => [ - 5, - ], - ], - 'flags' => [], - 'comments' => [], - 'extractedComments' => [ - 'Number of comments of the article', - ], - ], - [ - 'id' => 'This is a flagged element', - 'context' => null, - 'original' => 'This is a flagged element', - 'translation' => null, - 'plural' => null, - 'pluralTranslations' => [], - 'disabled' => false, - 'references' => [ - 'template.php' => [ - 10, - ], - ], - 'flags' => [ - 'a-code', - ], - 'comments' => [ - 'This is a comment', - ], - 'extractedComments' => [], - ], - [ - 'id' => 'This is a new translation', - 'context' => null, - 'original' => 'This is a new translation', - 'translation' => null, - 'plural' => null, - 'pluralTranslations' => [], - 'disabled' => false, - 'references' => [ - 'template.php' => [ - 11, - ], - ], - 'flags' => [], - 'comments' => [], - 'extractedComments' => [], - ], - ], -];