diff --git a/.gitignore b/.gitignore index 0bcd72b..9ee6cdf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ tests/temp/cache vendor +composer.lock diff --git a/composer.json b/composer.json index 4200cb1..e88b2ae 100644 --- a/composer.json +++ b/composer.json @@ -7,10 +7,15 @@ "nette/di": "^3.0", "nette/neon": "dev-master", "nette/safe-stream": "dev-master", - "nette/utils": "^3.0" + "nette/utils": "^3.0", + "psr/log": "^1.0" }, "require-dev": { - "nette/tester": "dev-master" + "nette/tester": "dev-master", + "mockery/mockery": "^1.0@dev" + }, + "suggest": { + "monolog/monolog": "To log translation errors." }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock deleted file mode 100644 index 2694ee4..0000000 --- a/composer.lock +++ /dev/null @@ -1,419 +0,0 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", - "This file is @generated automatically" - ], - "content-hash": "c453490609df856d65784afd0b779daa", - "packages": [ - { - "name": "nette/di", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/nette/di.git", - "reference": "0e29cc543e34dce9769b4dc545a7065f2b22a166" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/di/zipball/0e29cc543e34dce9769b4dc545a7065f2b22a166", - "reference": "0e29cc543e34dce9769b4dc545a7065f2b22a166", - "shasum": "" - }, - "require": { - "ext-tokenizer": "*", - "nette/neon": "^2.3.3 || ~3.0.0", - "nette/php-generator": "^3.0", - "nette/utils": "^2.4.3 || ~3.0.0", - "php": ">=7.0" - }, - "conflict": { - "nette/bootstrap": "<2.4", - "nette/nette": "<2.2" - }, - "require-dev": { - "nette/tester": "^2.0", - "tracy/tracy": "^2.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0", - "GPL-3.0" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "💎 Nette Dependency Injection Container: Flexible, compiled and full-featured DIC with perfectly usable autowiring and support for all new PHP 7.1 features.", - "homepage": "https://nette.org", - "keywords": [ - "compiled", - "di", - "dic", - "factory", - "ioc", - "nette", - "static" - ], - "time": "2017-09-29T14:01:38+00:00" - }, - { - "name": "nette/neon", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/nette/neon.git", - "reference": "01bf77f92e57d2143e934a24a95a992a3c781a91" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/neon/zipball/01bf77f92e57d2143e934a24a95a992a3c781a91", - "reference": "01bf77f92e57d2143e934a24a95a992a3c781a91", - "shasum": "" - }, - "require": { - "ext-iconv": "*", - "ext-json": "*", - "php": ">=7.0" - }, - "require-dev": { - "nette/tester": "^2.0", - "tracy/tracy": "^2.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0", - "GPL-3.0" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "🍸 Nette NEON: encodes and decodes NEON file format.", - "homepage": "http://ne-on.org", - "keywords": [ - "export", - "import", - "neon", - "nette", - "yaml" - ], - "time": "2017-09-26T13:39:11+00:00" - }, - { - "name": "nette/php-generator", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/nette/php-generator.git", - "reference": "f82c6d8351471648853ea191517d396528acf998" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/php-generator/zipball/f82c6d8351471648853ea191517d396528acf998", - "reference": "f82c6d8351471648853ea191517d396528acf998", - "shasum": "" - }, - "require": { - "nette/utils": "^2.4.2 || ~3.0.0", - "php": ">=7.1" - }, - "conflict": { - "nette/nette": "<2.2" - }, - "require-dev": { - "nette/tester": "^2.0", - "tracy/tracy": "^2.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.1-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0", - "GPL-3.0" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "🐘 Nette PHP Generator: generates neat PHP code for you. Supports new PHP 7.2 features.", - "homepage": "https://nette.org", - "keywords": [ - "code", - "nette", - "php", - "scaffolding" - ], - "time": "2017-09-26T13:40:40+00:00" - }, - { - "name": "nette/safe-stream", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/nette/safe-stream.git", - "reference": "48fc28741505a7c6da5e6d6bd96302442c76a9de" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/safe-stream/zipball/48fc28741505a7c6da5e6d6bd96302442c76a9de", - "reference": "48fc28741505a7c6da5e6d6bd96302442c76a9de", - "shasum": "" - }, - "require": { - "php": ">=5.3.1" - }, - "conflict": { - "nette/nette": "<2.2" - }, - "require-dev": { - "nette/tester": "~1.7", - "tracy/tracy": "^2.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.3-dev" - } - }, - "autoload": { - "files": [ - "src/loader.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0", - "GPL-3.0" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "Nette SafeStream: atomic and safe manipulation with files via native PHP functions.", - "homepage": "https://nette.org", - "keywords": [ - "atomic", - "filesystem", - "nette", - "safe" - ], - "time": "2017-09-26T13:59:49+00:00" - }, - { - "name": "nette/utils", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/nette/utils.git", - "reference": "9d5da31738553290e726e03075e9350f1785bfa3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/9d5da31738553290e726e03075e9350f1785bfa3", - "reference": "9d5da31738553290e726e03075e9350f1785bfa3", - "shasum": "" - }, - "require": { - "php": ">=7.0" - }, - "conflict": { - "nette/nette": "<2.2" - }, - "require-dev": { - "nette/tester": "~2.0", - "tracy/tracy": "^2.3" - }, - "suggest": { - "ext-gd": "to use Image", - "ext-iconv": "to use Strings::webalize() and toAscii()", - "ext-intl": "for script transliteration in Strings::webalize() and toAscii()", - "ext-json": "to use Nette\\Utils\\Json", - "ext-mbstring": "to use Strings::lower() etc...", - "ext-xml": "to use Strings::length() etc. when mbstring is not available" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0", - "GPL-3.0" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", - "homepage": "https://nette.org", - "keywords": [ - "array", - "core", - "datetime", - "images", - "json", - "nette", - "paginator", - "password", - "slugify", - "string", - "unicode", - "utf-8", - "utility", - "validation" - ], - "time": "2017-09-26T13:53:57+00:00" - } - ], - "packages-dev": [ - { - "name": "nette/tester", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/nette/tester.git", - "reference": "8cf24cf8c07defa85de7e2543c92db7bd3ded5a0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/tester/zipball/8cf24cf8c07defa85de7e2543c92db7bd3ded5a0", - "reference": "8cf24cf8c07defa85de7e2543c92db7bd3ded5a0", - "shasum": "" - }, - "require": { - "php": ">=5.6.0" - }, - "bin": [ - "src/tester" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0", - "GPL-3.0" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "Nette Tester: enjoyable unit testing in PHP with code coverage reporter. 🍏🍏🍎🍏", - "homepage": "https://tester.nette.org", - "keywords": [ - "Xdebug", - "assertions", - "clover", - "code coverage", - "nette", - "phpdbg", - "phpunit", - "testing", - "unit" - ], - "time": "2017-09-26T11:21:20+00:00" - } - ], - "aliases": [], - "minimum-stability": "dev", - "stability-flags": { - "nette/neon": 20, - "nette/safe-stream": 20, - "nette/tester": 20 - }, - "prefer-stable": false, - "prefer-lowest": false, - "platform": { - "php": ">=7.1" - }, - "platform-dev": [] -} diff --git a/src/Translate/Extension.php b/src/Translate/Extension.php index ae89e90..9fa5821 100644 --- a/src/Translate/Extension.php +++ b/src/Translate/Extension.php @@ -40,6 +40,7 @@ public function loadConfiguration() ->addDefinition($this->prefix('translator')) ->setFactory(Translator::class) ->addSetup('setLocale', [$config['default']]) + ->addSetup('setLocale', [$config['default']]) ->setAutowired(true); } diff --git a/src/Translate/Translator.php b/src/Translate/Translator.php index d43faa8..3baee14 100644 --- a/src/Translate/Translator.php +++ b/src/Translate/Translator.php @@ -3,20 +3,33 @@ namespace Rostenkowski\Translate; +use NumberFormatter; +use Psr\Log\LoggerInterface; use function array_key_exists; +use function array_shift; use function end; +use function func_get_args; +use function gettype; +use function is_numeric; use function is_object; use function key; use function method_exists; +use function sprintf; final class Translator implements TranslatorInterface { public const ZERO_INDEX = -1; + /** + * @var bool + */ public $useSpecialZeroForm = false; - public $throwExceptions = false; + /** + * @var bool + */ + public $debugMode = false; /** * current locale @@ -42,7 +55,6 @@ final class Translator implements TranslatorInterface */ private $defaultScheme = 'nplurals=2; plural=(n != 1)'; - /** * @var int */ @@ -58,6 +70,18 @@ final class Translator implements TranslatorInterface */ private $evalCache = []; + /** + * @var LoggerInterface + */ + private $logger; + + + public function setLogger(LoggerInterface $logger) + { + $this->logger = $logger; + } + + /** * locale-indexed map of irregular plural schemas * @@ -146,6 +170,28 @@ public function setLocale(string $locale): TranslatorInterface } + private function warn($message) + { + // format message + $args = func_get_args(); + if (count($args) > 1) { + array_shift($args); + $message = sprintf($message, ...$args); + } + + // log to psr logger + if ($this->logger !== NULL) { + $message = 'translator: ' . $message; + $this->logger->warning($message); + } + + // throw exception in debug mode + if ($this->isDebugMode()) { + throw new TranslatorException($message); + } + } + + public function translate($message, int $count = NULL): string { // avoid processing for empty values @@ -154,13 +200,24 @@ public function translate($message, int $count = NULL): string return ''; } + // numbers are formatted using locale settings (count parameter is used to define decimals) + if (is_numeric($message)) { + $formatter = new NumberFormatter($this->locale, NumberFormatter::DECIMAL); + $formatter->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, $count); + $formatter->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, $count); + + return $formatter->format($message); + } + + if (is_object($message) && method_exists($message, '__toString')) { + $message = (string) $message; + } + // check message to be string if (!is_string($message)) { - if (is_object($message) && method_exists($message, '__toString')) { - $message = (string) $message; - } else { - throw new TranslatorException(sprintf("Message must be string, but %s given.", var_export($message, true))); - } + $this->warn('Message must be string, but %s given.', gettype($message)); + + return ''; } // create dictionary on first access @@ -177,9 +234,8 @@ public function translate($message, int $count = NULL): string // process plural if (is_array($translation)) { - // strict mode - if ($count === NULL && $this->throwExceptions) { - throw new TranslatorException('NULL count provided for parametrized plural message.'); + if ($count === NULL) { + $this->warn('Multiple plural forms are available (message: %s), but the $count is NULL.', $message); } // choose the right plural form based on count @@ -188,10 +244,13 @@ public function translate($message, int $count = NULL): string $form = $this->plural($count); } - // count is NULL (silent mode) or the right plural form is not defined for this translation + if (!array_key_exists($form, $translation)) { + $this->warn('Plural form not defined. (message: %s, form: %s)', $message, $form); + } + if ($count === NULL || !array_key_exists($form, $translation)) { - // fallback to the last plural form defined + // fallback to last defined end($translation); $form = key($translation); } @@ -205,11 +264,10 @@ public function translate($message, int $count = NULL): string $result = $translation; } - // use untranslated message as translation for empty translation + // protection against accidentally empty-string translations if ($result === '') { $result = $message; } - } // process parameters @@ -236,6 +294,18 @@ public function translate($message, int $count = NULL): string } + public function isDebugMode(): bool + { + return $this->debugMode; + } + + + public function setDebugMode(bool $debugMode) + { + $this->debugMode = $debugMode; + } + + private function plural(int $count): int { // special zero diff --git a/tests/Translator.phpt b/tests/Translator.phpt index 9c9d99f..661fac5 100644 --- a/tests/Translator.phpt +++ b/tests/Translator.phpt @@ -3,8 +3,11 @@ namespace Rostenkowski\Translate; +use Psr\Log\LoggerInterface; use Rostenkowski\Translate\NeonDictionary\NeonDictionaryFactory; use Tester\Assert; +use const M_PI; +use function spy; require __DIR__ . '/bootstrap.php'; @@ -53,20 +56,17 @@ Assert::equal('Máte 5 nepřečtených zpráv.', $message = 'You have %s unread articles.'; Assert::same('Máte 5 nepřečtené články.', $translator->translate($message, 5)); -// test error: non-string message -$message = []; -Assert::exception(function () use ($translator, $message) { - $translator->translate($message); -}, TranslatorException::class, sprintf("Message must be string, but %s given.", var_export($message, true))); +// test error: non-string message in production mode +Assert::same('', $translator->translate([])); // test: NULL count Assert::same('Máte %s nepřečtených zpráv.', $translator->translate('You have %s unread messages.', NULL)); // test: NULL count in strict mode Assert::exception(function () use ($translator) { - $translator->throwExceptions = true; + $translator->setDebugMode(true); $translator->translate('You have %s unread messages.', NULL); -}, TranslatorException::class, 'NULL count provided for parametrized plural message.'); +}, TranslatorException::class, 'Multiple plural forms are available (message: You have %s unread messages.), but the $count is NULL.'); // test: accidentally empty translation Assert::same('Article author', $translator->translate('Article author')); @@ -74,7 +74,7 @@ Assert::same('Article author', $translator->translate('Article author')); // test: special form for the parametrized translation with count = 0 (zero) // special zero mode is opt-in $translator->useSpecialZeroForm = true; -$translator->throwExceptions = true; +$translator->setDebugMode(true); Assert::same("Čas vypršel", $translator->translate('You have %s seconds', 0)); Assert::same("Máte 1 vteřinu", $translator->translate('You have %s seconds', 1)); Assert::same("Máte 2 vteřiny", $translator->translate('You have %s seconds', 2)); @@ -86,4 +86,35 @@ Assert::same(5, $stats['evalCacheHitCounter']); Assert::same(6, $stats['evalCounter']); // test: string objects -Assert::same('foo', $translator->translate(new class { function __toString() { return 'foo'; }})); +Assert::same('foo', $translator->translate(new class +{ + + function __toString() { return 'foo'; } +})); + +// test: error: non-string message in debug mode +Assert::exception(function () use ($translator, $message) { + $translator->translate([]); +}, TranslatorException::class, 'Message must be string, but array given.'); + +// test: psr logger +$logger = spy(LoggerInterface::class); + +$translator->setDebugMode(false); +$translator->setLogger($logger); +$translator->translate([]); + +$logger->shouldHaveReceived()->warning('translator: Message must be string, but array given.'); + +// test: format number +$translator->setLocale('cs_CZ'); +Assert::same('3,14', $translator->translate(M_PI, 2)); + +$translator->setLocale('de_DE'); +Assert::same('3,14', $translator->translate(M_PI, 2)); + +$translator->setLocale('en_GB'); +Assert::same('3.14', $translator->translate(M_PI, 2)); + +$translator->setLocale('en_US'); +Assert::same('3.14', $translator->translate(M_PI, 2)); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6d6e73f..290c405 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -3,6 +3,7 @@ namespace Rostenkowski\Translate; +use Mockery; use Tester\Environment; $dir = dirname(__DIR__); @@ -12,3 +13,5 @@ @mkdir(__DIR__ . '/temp', 0775, true); Environment::setup(); + +Mockery::globalHelpers(); diff --git a/tests/php-coverage.ini b/tests/php-coverage.ini index 6136528..c24c62c 100644 --- a/tests/php-coverage.ini +++ b/tests/php-coverage.ini @@ -1,2 +1,3 @@ extension=tokenizer.so +extension=intl.so zend_extension=xdebug.so diff --git a/tests/php.ini b/tests/php.ini index ff10c97..89cff31 100644 --- a/tests/php.ini +++ b/tests/php.ini @@ -1 +1,2 @@ extension=tokenizer.so +extension=intl.so