From 26a7f14a6fcfec39128a5b0450d83f340392b2fb Mon Sep 17 00:00:00 2001 From: Dimitri Sitchet Tomkeu Date: Fri, 29 Nov 2024 17:23:34 +0100 Subject: [PATCH 01/11] feat: add support for localized strings --- src/Helper/InflectsString.php | 16 +++++ src/Helper/OutputHelper.php | 16 ++--- src/Helper/Shell.php | 10 +-- src/IO/Interactor.php | 7 ++- src/Input/Command.php | 18 +++--- src/Input/Parameter.php | 3 +- src/Input/Parser.php | 9 ++- src/Output/Color.php | 12 ++-- src/Output/ProgressBar.php | 11 ++-- src/Output/Table.php | 3 +- src/Translations/Translator.php | 105 ++++++++++++++++++++++++++++++++ src/Translations/en.php | 54 ++++++++++++++++ 12 files changed, 225 insertions(+), 39 deletions(-) create mode 100644 src/Translations/Translator.php create mode 100644 src/Translations/en.php diff --git a/src/Helper/InflectsString.php b/src/Helper/InflectsString.php index 243a8d6..73187c4 100644 --- a/src/Helper/InflectsString.php +++ b/src/Helper/InflectsString.php @@ -11,6 +11,8 @@ namespace Ahc\Cli\Helper; +use Ahc\Cli\Translations\Translator; + use function lcfirst; use function mb_strwidth; use function mb_substr; @@ -30,6 +32,8 @@ */ trait InflectsString { + private static ?Translator $translator = null; + /** * Convert a string to camel case. */ @@ -75,4 +79,16 @@ public function substr(string $string, int $start, ?int $length = null): string return substr($string, $start, $length); } + + /** + * Translates a message using the translator. + */ + public static function translate(string $key, array $args = []): string + { + if (self::$translator === null) { + self::$translator = new Translator(); + } + + return self::$translator->getMessage($key, $args); + } } diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index 5f54200..4deaae3 100644 --- a/src/Helper/OutputHelper.php +++ b/src/Helper/OutputHelper.php @@ -58,6 +58,8 @@ */ class OutputHelper { + use InflectsString; + protected Writer $writer; /** @var int Max width of command name */ @@ -77,7 +79,7 @@ public function printTrace(Throwable $e): void $this->writer->colors( "{$eClass} {$e->getMessage()}" . - "(thrown in {$e->getFile()}:{$e->getLine()})" + "({$this->translate('thrownIn')} {$e->getFile()}:{$e->getLine()})" ); // @codeCoverageIgnoreStart @@ -87,7 +89,7 @@ public function printTrace(Throwable $e): void } // @codeCoverageIgnoreEnd - $traceStr = 'Stack Trace:'; + $traceStr = "{$this->translate('stackTrace')}:"; foreach ($e->getTrace() as $i => $trace) { $trace += ['class' => '', 'type' => '', 'function' => '', 'file' => '', 'line' => '', 'args' => []]; @@ -185,7 +187,7 @@ protected function showHelp(string $for, array $items, string $header = '', stri $this->writer->help_header($header, true); } - $this->writer->eol()->help_category($for . ':', true); + $this->writer->eol()->help_category($this->translate(strtolower($for)) . ':', true); if (empty($items)) { $this->writer->help_text(' (n/a)', true); @@ -229,7 +231,7 @@ public function showUsage(string $usage): self $usage = str_replace('$0', $_SERVER['argv'][0] ?? '[cmd]', $usage); if (!str_contains($usage, ' ## ')) { - $this->writer->eol()->help_category('Usage Examples:', true)->colors($usage)->eol(); + $this->writer->eol()->help_category($this->translate('usageExamples') . ':', true)->colors($usage)->eol(); return $this; } @@ -246,7 +248,7 @@ public function showUsage(string $usage): self return str_pad('# ', $maxlen - array_shift($lines), ' ', STR_PAD_LEFT); }, $usage); - $this->writer->eol()->help_category('Usage Examples:', true)->colors($usage)->eol(); + $this->writer->eol()->help_category($this->translate('usageExamples') . ':', true)->colors($usage)->eol(); return $this; } @@ -261,11 +263,11 @@ public function showCommandNotFound(string $attempted, array $available): self } } - $this->writer->error("Command $attempted not found", true); + $this->writer->error($this->translate('commandNotFound', [$attempted]), true); if ($closest) { asort($closest); $closest = key($closest); - $this->writer->bgRed("Did you mean $closest?", true); + $this->writer->bgRed($this->translate('commandSuggestion', [$closest]), true); } return $this; diff --git a/src/Helper/Shell.php b/src/Helper/Shell.php index 3666d5d..8720b4a 100644 --- a/src/Helper/Shell.php +++ b/src/Helper/Shell.php @@ -37,6 +37,8 @@ */ class Shell { + use InflectsString; + const STDIN_DESCRIPTOR_KEY = 0; const STDOUT_DESCRIPTOR_KEY = 1; const STDERR_DESCRIPTOR_KEY = 2; @@ -99,7 +101,7 @@ public function __construct(protected string $command, protected ?string $input { // @codeCoverageIgnoreStart if (!function_exists('proc_open')) { - throw new RuntimeException('Required proc_open could not be found in your PHP setup.'); + throw new RuntimeException($this->translate('procOpenMissing')); } // @codeCoverageIgnoreEnd @@ -181,7 +183,7 @@ protected function checkTimeout(): void if ($executionDuration > $this->processTimeout) { $this->kill(); - throw new RuntimeException('Timeout occurred, process terminated.'); + throw new RuntimeException($this->translate('timeoutOccured')); } // @codeCoverageIgnoreStart } @@ -216,7 +218,7 @@ public function setOptions( public function execute(bool $async = false, ?array $stdin = null, ?array $stdout = null, ?array $stderr = null): self { if ($this->isRunning()) { - throw new RuntimeException('Process is already running.'); + throw new RuntimeException($this->translate('processAlreadyRun')); } $this->descriptors = $this->prepareDescriptors($stdin, $stdout, $stderr); @@ -234,7 +236,7 @@ public function execute(bool $async = false, ?array $stdin = null, ?array $stdou // @codeCoverageIgnoreStart if (!is_resource($this->process)) { - throw new RuntimeException('Bad program could not be started.'); + throw new RuntimeException($this->translate('badProgram')); } // @codeCoverageIgnoreEnd diff --git a/src/IO/Interactor.php b/src/IO/Interactor.php index 9112383..738c9cc 100644 --- a/src/IO/Interactor.php +++ b/src/IO/Interactor.php @@ -11,6 +11,7 @@ namespace Ahc\Cli\IO; +use Ahc\Cli\Helper\InflectsString; use Ahc\Cli\Input\Reader; use Ahc\Cli\Output\Writer; use Throwable; @@ -195,6 +196,8 @@ */ class Interactor { + use InflectsString; + protected Reader $reader; protected Writer $writer; @@ -312,7 +315,7 @@ public function choices(string $text, array $choices, $default = null, bool $cas */ public function prompt(string $text, $default = null, ?callable $fn = null, int $retry = 3): mixed { - $error = 'Invalid value. Please try again!'; + $error = $this->translate('promptInvalidValue'); $hidden = func_get_args()[4] ?? false; $readFn = ['read', 'readHidden'][(int) $hidden]; @@ -370,7 +373,7 @@ protected function listOptions(array $choices, $default = null, bool $multi = fa $this->writer->eol()->choice(str_pad(" [$choice]", $maxLen + 6))->answer($desc); } - $label = $multi ? 'Choices (comma separated)' : 'Choice'; + $label = $this->translate($multi ? 'choices' : 'choice'); $this->writer->eol()->question($label); diff --git a/src/Input/Command.php b/src/Input/Command.php index 0cfcf3b..a2dda09 100644 --- a/src/Input/Command.php +++ b/src/Input/Command.php @@ -83,9 +83,9 @@ public function __construct( */ protected function defaults(): self { - $this->option('-h, --help', 'Show help')->on([$this, 'showHelp']); - $this->option('-V, --version', 'Show version')->on([$this, 'showVersion']); - $this->option('-v, --verbosity', 'Verbosity level', null, 0)->on( + $this->option('-h, --help', $this->translate('showHelp'))->on([$this, 'showHelp']); + $this->option('-V, --version', $this->translate('showVersion'))->on([$this, 'showVersion']); + $this->option('-v, --verbosity', $this->translate('verbosityLevel'), null, 0)->on( fn () => $this->set('verbosity', ($this->verbosity ?? 0) + 1) && false ); @@ -196,7 +196,7 @@ public function argument(string $raw, string $desc = '', $default = null): self $argument = new Argument($raw, $desc, $default); if ($this->_argVariadic) { - throw new InvalidParameterException('Only last argument can be variadic'); + throw new InvalidParameterException($this->translate('argumentVariadic')); } if ($argument->variadic()) { @@ -303,9 +303,7 @@ protected function handleUnknown(string $arg, ?string $value = null): mixed // Has some value, error! if ($values) { - throw new RuntimeException( - sprintf('Option "%s" not registered', $arg) - ); + throw new RuntimeException($this->translate('optionNotRegistered', [$arg])); } // Has no value, show help! @@ -358,13 +356,13 @@ public function showDefaultHelp(): mixed $io->logo($logo, true); } - $io->help_header("Command {$this->_name}, version {$this->_version}", true)->eol(); + $io->help_header("{$this->translate('command')} {$this->_name}, {$this->translate('version')} {$this->_version}", true)->eol(); $io->help_summary($this->_desc, true)->eol(); - $io->help_text('Usage: ')->help_example("{$this->_name} [OPTIONS...] [ARGUMENTS...]", true); + $io->help_text("{$this->translate('usage')}: ")->help_example("{$this->_name} {$this->translate('helpExample')}", true); $helper ->showArgumentsHelp($this->allArguments()) - ->showOptionsHelp($this->allOptions(), '', 'Legend: [optional] variadic...'); + ->showOptionsHelp($this->allOptions(), '', $this->translate('optionHelp')); if ($this->_usage) { $helper->showUsage($this->_usage); diff --git a/src/Input/Parameter.php b/src/Input/Parameter.php index 46236ff..791155b 100644 --- a/src/Input/Parameter.php +++ b/src/Input/Parameter.php @@ -16,7 +16,6 @@ use function json_encode; use function ltrim; use function strpos; -use function sprintf; /** * Cli Parameter. @@ -84,7 +83,7 @@ public function desc(bool $withDefault = false): string return $this->desc; } - return ltrim(sprintf('%s [default: %s]', $this->desc, json_encode($this->default))); + return ltrim($this->translate('descWithDefault', [$this->desc, json_encode($this->default)])); } /** diff --git a/src/Input/Parser.php b/src/Input/Parser.php index 42977ca..2b0c5f3 100644 --- a/src/Input/Parser.php +++ b/src/Input/Parser.php @@ -13,6 +13,7 @@ use Ahc\Cli\Exception\InvalidParameterException; use Ahc\Cli\Exception\RuntimeException; +use Ahc\Cli\Helper\InflectsString; use Ahc\Cli\Helper\Normalizer; use InvalidArgumentException; @@ -37,6 +38,8 @@ */ abstract class Parser { + use InflectsString; + /** @var string|null The last seen variadic option name */ protected ?string $_lastVariadic = null; @@ -264,9 +267,9 @@ public function unset(string $name): self protected function ifAlreadyRegistered(Parameter $param): void { if ($this->registered($param->attributeName())) { - throw new InvalidParameterException(sprintf( - 'The parameter "%s" is already registered', - $param instanceof Option ? $param->long() : $param->name() + throw new InvalidParameterException($this->translate( + 'parameterAlreadyRegistered', + [$param instanceof Option ? $param->long() : $param->name()] )); } } diff --git a/src/Output/Color.php b/src/Output/Color.php index 876b503..b6560d7 100644 --- a/src/Output/Color.php +++ b/src/Output/Color.php @@ -12,6 +12,7 @@ namespace Ahc\Cli\Output; use Ahc\Cli\Exception\InvalidArgumentException; +use Ahc\Cli\Helper\InflectsString; use function array_intersect_key; use function constant; @@ -19,7 +20,6 @@ use function lcfirst; use function method_exists; use function preg_match_all; -use function sprintf; use function str_ireplace; use function str_replace; use function stripos; @@ -39,6 +39,8 @@ */ class Color { + use InflectsString; + const BLACK = 30; const RED = 31; const GREEN = 32; @@ -192,12 +194,12 @@ public static function style(string $name, array $style): void $style = array_intersect_key($style, $allow); if (empty($style)) { - throw new InvalidArgumentException('Trying to set empty or invalid style'); + throw new InvalidArgumentException(self::translate('usingInvalidStyle')); } $invisible = (isset($style['bg']) && isset($style['fg']) && $style['bg'] === $style['fg']); if ($invisible && method_exists(static::class, $name)) { - throw new InvalidArgumentException('Built-in styles cannot be invisible'); + throw new InvalidArgumentException(self::translate('styleInvisible')); } static::$styles[$name] = $style; @@ -214,7 +216,7 @@ public static function style(string $name, array $style): void public function __call(string $name, array $arguments): string { if (!isset($arguments[0])) { - throw new InvalidArgumentException('Text required'); + throw new InvalidArgumentException($this->translate('textRequired')); } [$name, $text, $style] = $this->parseCall($name, $arguments); @@ -229,7 +231,7 @@ public function __call(string $name, array $arguments): string } if (!method_exists($this, $name)) { - throw new InvalidArgumentException(sprintf('Style "%s" not defined', $name)); + throw new InvalidArgumentException($this->translate('undefinedStyle', [$name])); } return $this->{$name}($text, $style); diff --git a/src/Output/ProgressBar.php b/src/Output/ProgressBar.php index dcb4e27..259a971 100644 --- a/src/Output/ProgressBar.php +++ b/src/Output/ProgressBar.php @@ -11,6 +11,7 @@ namespace Ahc\Cli\Output; +use Ahc\Cli\Helper\InflectsString; use Ahc\Cli\Helper\Terminal; use UnexpectedValueException; @@ -31,6 +32,8 @@ */ class ProgressBar { + use InflectsString; + /** * The total number of items involved. */ @@ -133,7 +136,7 @@ public function option(string|array $key, ?string $value = null): self { if (is_string($key)) { if (empty($value)) { - throw new UnexpectedValueException('configuration option value is required'); + throw new UnexpectedValueException($this->translate('configOptionMissing')); } $key = [$key => $value]; @@ -163,11 +166,11 @@ public function current(int $current, string $label = '') { if ($this->total == 0) { // Avoid dividing by 0 - throw new UnexpectedValueException('The progress total must be greater than zero.'); + throw new UnexpectedValueException($this->translate('progressbarTotalMin')); } - + if ($current > $this->total) { - throw new UnexpectedValueException(sprintf('The current (%d) is greater than the total (%d).', $current, $this->total)); + throw new UnexpectedValueException($this->translate('progressbarCurrentMax', [$current, $this->total])); } $this->drawProgressBar($current, $label); diff --git a/src/Output/Table.php b/src/Output/Table.php index 41e6303..9b651f6 100644 --- a/src/Output/Table.php +++ b/src/Output/Table.php @@ -24,7 +24,6 @@ use function is_array; use function max; use function reset; -use function sprintf; use function str_repeat; use function trim; @@ -107,7 +106,7 @@ protected function normalize(array $rows): array if (!is_array($head)) { throw new InvalidArgumentException( - sprintf('Rows must be array of assoc arrays, %s given', gettype($head)) + $this->translate('invalidTableRowsType', [gettype($head)]) ); } diff --git a/src/Translations/Translator.php b/src/Translations/Translator.php new file mode 100644 index 0000000..b9f219d --- /dev/null +++ b/src/Translations/Translator.php @@ -0,0 +1,105 @@ + + * + * + * Licensed under MIT license. + */ + +namespace Ahc\Cli\Translations; + +use function array_merge; +use function is_file; +use function sprintf; + +/** + * Translation support of CLI + * + * @author Dimitri Sitchet Tomkeu + * @license MIT + * + * @link https://github.com/adhocore/cli + */ +class Translator +{ + /** + * Locale of CLI. + */ + public static string $locale = 'en'; + + /** + * Folder in which the translation files are to be searched. + */ + public static string $translations_dir = __DIR__; + + /** + * Stores translations recovered from files for faster recovery when used a second time. + * + * @var array + * + * @example + * ```php + * ['locale' => ['key1' => 'value1', 'key2' => 'value2']] + * ``` + */ + private static array $translations = []; + + /** + * Constructor. + */ + public function __construct() + { + $this->loadDefaultTranslations(); + } + + /** + * Retrieves a translated string based on the provided key and optional arguments. + * + * @param string $key The key of the translation string. + * @param array $args Optional arguments to replace placeholders in the translation string. + */ + public function getMessage(string $key, array $args = []): string + { + if ($key === '') { + return ''; + } + + $this->loadTranslations(); + + $message = self::$translations[self::$locale][$key] ?? ''; + + return $args === [] ? $message : sprintf($message, ...$args); + } + + /** + * Loads translations for the current locale if they haven't been loaded yet. + * + * This method checks if translations for the current locale exist in the static + * $translations array. If not, it attempts to load them from a PHP file in the + * translations directory. The file name is expected to match the locale name. + * If the file exists, its contents are merged with the default translations. + */ + protected function loadTranslations(): void + { + if (!isset(self::$translations[self::$locale])) { + $path = self::$translations_dir . '/' . self::$locale . '.php'; + + $translations = is_file($path) ? require $path : []; + + self::$translations[self::$locale] = array_merge(self::$translations['__default__'], $translations); + } + } + + /** + * Loads the default translations for the application. + */ + protected function loadDefaultTranslations(): void + { + if (!isset(self::$translations['__default__'])) { + self::$translations['__default__'] = require __DIR__ . '/en.php'; + } + } +} diff --git a/src/Translations/en.php b/src/Translations/en.php new file mode 100644 index 0000000..c637c1e --- /dev/null +++ b/src/Translations/en.php @@ -0,0 +1,54 @@ + + * + * + * Licensed under MIT license. + */ + +return [ + // main + 'arguments' => 'Arguments', + 'choice' => 'Choice', + 'choices' => 'Choices (comma separated)', + 'command' => 'Command', + 'commandNotFound' => 'Command %s not found', + 'commandSuggestion' => 'Did you mean %s?', + 'commands' => 'Commands', + 'descWithDefault' => '%s [default: %s]', + 'helpExample' => '[OPTIONS...] [ARGUMENTS...]', + 'optionHelp' => 'Legend: [optional] variadic...', + 'options' => 'Options', + 'promptInvalidValue' => 'Invalid value. Please try again!', + 'showHelp' => 'Show help', + 'showHelpFooter' => 'Run ` --help` for specific help', + 'showVersion' => 'Show version', + 'stackTrace' => 'Stack Trace', + 'thrownIn' => 'thrown in', + 'usage' => 'Usage', + 'usageExamples' => 'Usage Examples', + 'version' => 'version', + 'verbosityLevel' => 'Verbosity level', + + // exceptions + 'argumentVariadic' => 'Only last argument can be variadic', + 'badProgram' => 'Bad program could not be started.', + 'commandAlreadyAdded' => 'Command "%s" already added', + 'commandDoesNotExist' => 'Command "%s" does not exist', + 'configOptionMissing' => 'Configuration option value is required', + 'invalidTableRowsType' => 'Rows must be array of assoc arrays, %s given', + 'optionNotRegistered' => 'Option "%s" not registered', + 'parameterAlreadyRegistered' => 'The parameter "%s" is already registered', + 'processAlreadyRun' => 'Process is already running.', + 'procOpenMissing' => 'Required proc_open could not be found in your PHP setup.', + 'progressbarCurrentMax' => 'The current (%d) is greater than the total (%d).', + 'progressbarTotalMin' => 'The progress total must be greater than zero.', + 'styleInvisible' => 'Built-in styles cannot be invisible', + 'textRequired' => 'Text required', + 'timeoutOccured' => 'Timeout occurred, process terminated.', + 'undefinedStyle' => 'Style "%s" not defined', + 'usingInvalidStyle' => 'Trying to set empty or invalid style', +]; From a5d6313f536170f941d0b4bf4fa604577745d4b5 Mon Sep 17 00:00:00 2001 From: Dimitri Sitchet Tomkeu Date: Fri, 29 Nov 2024 18:19:41 +0100 Subject: [PATCH 02/11] test: write tests for translation manager --- tests/Translations/TranslatorTest.php | 217 ++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 tests/Translations/TranslatorTest.php diff --git a/tests/Translations/TranslatorTest.php b/tests/Translations/TranslatorTest.php new file mode 100644 index 0000000..ec45c0a --- /dev/null +++ b/tests/Translations/TranslatorTest.php @@ -0,0 +1,217 @@ +translator = new Translator(); + } + + public static function setUpBeforeClass(): void + { + self::$baseTranslationsDir = Translator::$translations_dir; + } + + protected function tearDown(): void + { + Translator::$translations_dir = self::$baseTranslationsDir; + } + + public function test_get_message_returns_empty_string_for_empty_key(): void + { + $result = $this->translator->getMessage(''); + $this->assertSame('', $result); + } + + public function test_default_translations_loaded_on_instantiation(): void + { + $reflector = new ReflectionClass(Translator::class); + $translationsProperty = $reflector->getProperty('translations'); + $translationsProperty->setAccessible(true); + + $translations = $translationsProperty->getValue(); + + $this->assertArrayHasKey('__default__', $translations); + $this->assertNotEmpty($translations['__default__']); + $this->assertSame(require self::$baseTranslationsDir . '/en.php', $translations['__default__']); + } + + public function test_get_message_returns_correct_translation_for_default_locale(): void + { + $key = 'badProgram'; + $expectedTranslation = 'Bad program could not be started.'; + + $result = $this->translator->getMessage($key); + + $this->assertSame($expectedTranslation, $result); + } + + public function test_get_message_replaces_placeholders_with_arguments(): void + { + $key = 'test_placeholder'; + $message = 'Hello, %s! You are %d years old.'; + $args = ['John', 30]; + $expectedResult = 'Hello, John! You are 30 years old.'; + + // Set up a mock translation + $reflector = new ReflectionClass(Translator::class); + $translationsProperty = $reflector->getProperty('translations'); + $translationsProperty->setAccessible(true); + $translationsProperty->setValue([ + 'en' => [$key => $message] + ]); + + $result = $this->translator->getMessage($key, $args); + + $this->assertSame($expectedResult, $result); + } + + public function test_get_message_returns_empty_string_for_non_existent_key(): void + { + $nonExistentKey = 'non_existent_key'; + $result = $this->translator->getMessage($nonExistentKey); + $this->assertSame('', $result); + } + + public function test_load_translations_for_non_default_locale(): void + { + $customLocale = 'french'; // dont use `fr` because it's a true locale and file exist + Translator::$locale = $customLocale; + + // Create a mock translation file for the custom locale + $mockTranslations = [ + 'test_key' => 'Test en français', + ]; + $mockFilePath = Translator::$translations_dir . '/' . $customLocale . '.php'; + file_put_contents($mockFilePath, 'getMessage('test_key'); + + $this->assertSame('Test en français', $result); + + // Clean up + unlink($mockFilePath); + Translator::$locale = 'en'; + } + + public function test_merge_custom_translations_with_default_translations(): void + { + $customLocale = 'fr'; + Translator::$locale = $customLocale; + Translator::$translations_dir = __DIR__; + + // Create mock custom translations + $customTranslations = [ + 'usage' => 'Utilisation', + ]; + + $mockCustomPath = Translator::$translations_dir . '/' . $customLocale . '.php'; + file_put_contents($mockCustomPath, 'assertSame('Utilisation', $translator->getMessage('usage')); + $this->assertSame('Usage Examples', $translator->getMessage('usageExamples')); + + unlink($mockCustomPath); + Translator::$locale = 'en'; + } + + public function test_use_cached_translations_when_requesting_same_locale_multiple_times(): void + { + $customLocale = 'fr'; + Translator::$locale = $customLocale; + Translator::$translations_dir = __DIR__; + + // Create a mock translation file for the custom locale + $mockTranslations = [ + 'test_key' => 'Test en français', + ]; + $mockFilePath = Translator::$translations_dir . '/' . $customLocale . '.php'; + file_put_contents($mockFilePath, 'getMessage('test_key'); + $this->assertSame('Test en français', $result1); + + // Modify the mock file to ensure we're using cached translations + file_put_contents($mockFilePath, ' 'Modified test'], true) . ';'); + + // Second call should use cached translations + $result2 = $translator->getMessage('test_key'); + $this->assertSame('Test en français', $result2); + + // Clean up + unlink($mockFilePath); + Translator::$locale = 'en'; + } + + public function test_handle_non_existent_translation_file(): void + { + $nonExistentLocale = 'xx'; + Translator::$locale = $nonExistentLocale; + + // Ensure the translation file doesn't exist + $nonExistentPath = Translator::$translations_dir . '/' . $nonExistentLocale . '.php'; + $this->assertFileDoesNotExist($nonExistentPath); + + $translator = new Translator(); + + // Test with a key that exists in the default translations + $defaultKey = 'badProgram'; + $expectedDefaultTranslation = 'Bad program could not be started.'; + $result = $translator->getMessage($defaultKey); + $this->assertSame($expectedDefaultTranslation, $result); + + // Test with a key that doesn't exist in the default translations + $nonExistentKey = 'non_existent_key'; + $result = $translator->getMessage($nonExistentKey); + $this->assertSame('', $result); + + // Reset the locale + Translator::$locale = 'en'; + } + + public function test_change_locale_after_initial_translations_loaded(): void + { + Translator::$translations_dir = __DIR__; + + // Set up initial locale and translations + Translator::$locale = 'en'; + $translator = new Translator(); + $initialMessage = $translator->getMessage('badProgram'); + + // Change locale to a new one + $newLocale = 'fr'; + Translator::$locale = $newLocale; + + // Create mock translations for the new locale + $mockTranslations = [ + 'badProgram' => 'Mauvais programme', + ]; + $mockFilePath = Translator::$translations_dir . '/' . $newLocale . '.php'; + file_put_contents($mockFilePath, 'getMessage('badProgram'); + + // Assert that the messages are different + $this->assertNotSame($initialMessage, $newMessage); + $this->assertSame('Mauvais programme', $newMessage); + + // Clean up + unlink($mockFilePath); + Translator::$locale = 'en'; + } +} From dae519ba8a21c14c65f953e6b2357c3c1616e888 Mon Sep 17 00:00:00 2001 From: Dimitri Sitchet Tomkeu Date: Fri, 29 Nov 2024 18:42:23 +0100 Subject: [PATCH 03/11] patch: add a missing translation key --- src/Helper/OutputHelper.php | 2 +- src/Translations/en.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index 4deaae3..f8f1158 100644 --- a/src/Helper/OutputHelper.php +++ b/src/Helper/OutputHelper.php @@ -99,7 +99,7 @@ public function printTrace(Throwable $e): void $traceStr .= " $i) $symbol($args)"; if ('' !== $trace['file']) { $file = realpath($trace['file']); - $traceStr .= " at $file:{$trace['line']}"; + $traceStr .= " {$this->translate('thrownAt')} $file:{$trace['line']}"; } } diff --git a/src/Translations/en.php b/src/Translations/en.php index c637c1e..8a0e9ea 100644 --- a/src/Translations/en.php +++ b/src/Translations/en.php @@ -28,6 +28,7 @@ 'showVersion' => 'Show version', 'stackTrace' => 'Stack Trace', 'thrownIn' => 'thrown in', + 'thrownAt' => 'at', 'usage' => 'Usage', 'usageExamples' => 'Usage Examples', 'version' => 'version', From 733285ebbfa0ff472fd067b7a3e4bb83ecfb9ece Mon Sep 17 00:00:00 2001 From: Dimitri Sitchet Tomkeu Date: Fri, 29 Nov 2024 19:52:51 +0100 Subject: [PATCH 04/11] provides `fr`, `es`, `de` and `ru` translations --- src/Translations/de.php | 55 +++++++++++++++++++++++++++++++++++++++++ src/Translations/es.php | 55 +++++++++++++++++++++++++++++++++++++++++ src/Translations/fr.php | 55 +++++++++++++++++++++++++++++++++++++++++ src/Translations/ru.php | 55 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+) create mode 100644 src/Translations/de.php create mode 100644 src/Translations/es.php create mode 100644 src/Translations/fr.php create mode 100644 src/Translations/ru.php diff --git a/src/Translations/de.php b/src/Translations/de.php new file mode 100644 index 0000000..96d367a --- /dev/null +++ b/src/Translations/de.php @@ -0,0 +1,55 @@ + + * + * + * Licensed under MIT license. + */ + +return [ + // main + 'arguments' => 'Argumente', + 'choice' => 'Wahl', + 'choices' => 'Wahlmöglichkeiten (durch Komma getrennt)', + 'command' => 'Befehl', + 'commandNotFound' => 'Befehl %s nicht gefunden', + 'commandSuggestion' => 'Meinten Sie %s?', + 'commands' => 'Befehle', + 'descWithDefault' => '%s [Standard: %s]', + 'helpExample' => '[OPTIONEN...] [ARGUMENTE...]', + 'optionHelp' => 'Legende: [optional] variadisch...', + 'options' => 'Optionen', + 'promptInvalidValue' => 'Ungültiger Wert. Versuchen Sie es erneut!', + 'showHelp' => 'Hilfe anzeigen', + 'showHelpFooter' => 'Führen Sie ` --help` für spezifische Hilfe aus', + 'showVersion' => 'Version anzeigen', + 'stackTrace' => 'Stack Trace', + 'thrownIn' => 'geworfen in', + 'thrownAt' => 'bei', + 'usage' => 'Verwendung', + 'usageExamples' => 'Verwendungsbeispiele', + 'version' => 'Version', + 'verbosityLevel' => 'Verbosity-Level', + + // exceptions + 'argumentVariadic' => 'Nur das letzte Argument kann variadisch sein', + 'badProgram' => 'Das fehlerhafte Programm konnte nicht gestartet werden.', + 'commandAlreadyAdded' => 'Befehl "%s" wurde bereits hinzugefügt', + 'commandDoesNotExist' => 'Befehl "%s" existiert nicht', + 'configOptionMissing' => 'Der Wert der Konfigurationsoption wird benötigt', + 'invalidTableRowsType' => 'Zeilen müssen ein Array von assoziativen Arrays sein, %s gegeben', + 'optionNotRegistered' => 'Option "%s" nicht registriert', + 'parameterAlreadyRegistered' => 'Der Parameter "%s" ist bereits registriert', + 'processAlreadyRun' => 'Der Prozess läuft bereits.', + 'procOpenMissing' => 'Die Funktion proc_open fehlt in Ihrer PHP-Konfiguration.', + 'progressbarCurrentMax' => 'Der aktuelle (%d) ist größer als der Gesamtwert (%d).', + 'progressbarTotalMin' => 'Der Gesamtwert der Fortschrittsanzeige muss größer als null sein.', + 'styleInvisible' => 'Eingebaute Stile können nicht unsichtbar sein', + 'textRequired' => 'Text erforderlich', + 'timeoutOccured' => 'Zeitüberschreitung, Prozess beendet.', + 'undefinedStyle' => 'Stil "%s" nicht definiert', + 'usingInvalidStyle' => 'Versuch, einen leeren oder ungültigen Stil festzulegen' +]; diff --git a/src/Translations/es.php b/src/Translations/es.php new file mode 100644 index 0000000..1b3ff1c --- /dev/null +++ b/src/Translations/es.php @@ -0,0 +1,55 @@ + + * + * + * Licensed under MIT license. + */ + +return [ + // main + 'arguments' => 'Argumentos', + 'choice' => 'Elección', + 'choices' => 'Opciones (separadas por coma)', + 'command' => 'Comando', + 'commandNotFound' => 'Comando %s no encontrado', + 'commandSuggestion' => '¿Quiso decir %s?', + 'commands' => 'Comandos', + 'descWithDefault' => '%s [por defecto: %s]', + 'helpExample' => '[OPCIONES...] [ARGUMENTOS...]', + 'optionHelp' => 'Leyenda: [opcional] variadic...', + 'options' => 'Opciones', + 'promptInvalidValue' => 'Valor no válido. ¡Intente nuevamente!', + 'showHelp' => 'Mostrar ayuda', + 'showHelpFooter' => 'Ejecute ` --help` para ayuda específica', + 'showVersion' => 'Mostrar versión', + 'stackTrace' => 'Rastro de pila', + 'thrownIn' => 'lanzado en', + 'thrownAt' => 'en', + 'usage' => 'Uso', + 'usageExamples' => 'Ejemplos de uso', + 'version' => 'versión', + 'verbosityLevel' => 'Nivel de verbosidad', + + // translations + 'argumentVariadic' => 'Solo el último argumento puede ser variádico', + 'badProgram' => 'No se pudo iniciar el programa defectuoso.', + 'commandAlreadyAdded' => 'El comando "%s" ya fue añadido', + 'commandDoesNotExist' => 'El comando "%s" no existe', + 'configOptionMissing' => 'Se requiere el valor de la opción de configuración', + 'invalidTableRowsType' => 'Las filas deben ser un array de arrays asociativos, %s dado', + 'optionNotRegistered' => 'La opción "%s" no está registrada', + 'parameterAlreadyRegistered' => 'El parámetro "%s" ya está registrado', + 'processAlreadyRun' => 'El proceso ya está en ejecución.', + 'procOpenMissing' => 'La función proc_open no está disponible en su configuración PHP.', + 'progressbarCurrentMax' => 'El %d actual es mayor que el total %d.', + 'progressbarTotalMin' => 'El total de la barra de progreso debe ser mayor que cero.', + 'styleInvisible' => 'Los estilos incorporados no pueden ser invisibles', + 'textRequired' => 'Texto requerido', + 'timeoutOccured' => 'Tiempo agotado, proceso terminado.', + 'undefinedStyle' => 'Estilo "%s" no definido', + 'usingInvalidStyle' => 'Intentando establecer un estilo vacío o inválido' +]; diff --git a/src/Translations/fr.php b/src/Translations/fr.php new file mode 100644 index 0000000..d420f32 --- /dev/null +++ b/src/Translations/fr.php @@ -0,0 +1,55 @@ + + * + * + * Licensed under MIT license. + */ + +return [ + // main + 'arguments' => 'Arguments', + 'choice' => 'Choix', + 'choices' => 'Choix (séparés par des virgules)', + 'command' => 'Commande', + 'commandNotFound' => 'Commande %s non trouvée', + 'commandSuggestion' => 'Vouliez-vous dire %s ?', + 'commands' => 'Commandes', + 'descWithDefault' => '%s [par défaut : %s]', + 'helpExample' => '[OPTIONS...] [ARGUMENTS...]', + 'optionHelp' => 'Légende : [optionnel] variadique...', + 'options' => 'Options', + 'promptInvalidValue' => 'Valeur invalide. Essayez à nouveau!', + 'showHelp' => 'Afficher l\'aide', + 'showHelpFooter' => 'Exécutez ` --help` pour de l\'aide spécifique', + 'showVersion' => 'Afficher la version', + 'stackTrace' => 'Trace de la pile', + 'thrownIn' => 'levée dans', + 'thrownAt' => 'à', + 'usage' => 'Utilisation', + 'usageExamples' => 'Exemples d\'utilisation', + 'version' => 'version', + 'verbosityLevel' => 'Niveau de verbosité', + + // exceptions + 'argumentVariadic' => 'Seul le dernier argument peut être variadique', + 'badProgram' => 'Le programme défectueux n\'a pas pu être démarré.', + 'commandAlreadyAdded' => 'La commande "%s" a déjà été ajoutée', + 'commandDoesNotExist' => 'La commande "%s" n\'existe pas', + 'configOptionMissing' => 'La valeur de l\'option de configuration est requise', + 'invalidTableRowsType' => 'Les lignes doivent être un tableau de tableaux associatifs, %s donné', + 'optionNotRegistered' => 'L\'option "%s" n\'est pas enregistrée', + 'parameterAlreadyRegistered' => 'Le paramètre "%s" est déjà enregistré', + 'processAlreadyRun' => 'Le processus est déjà en cours.', + 'procOpenMissing' => 'La fonction proc_open est manquante dans votre configuration PHP.', + 'progressbarCurrentMax' => 'Le %d actuel est supérieur au total %d.', + 'progressbarTotalMin' => 'Le total de la barre de progression doit être supérieur à zéro.', + 'styleInvisible' => 'Les styles intégrés ne peuvent pas être invisibles', + 'textRequired' => 'Texte requis', + 'timeoutOccured' => 'Délai dépassé, processus interrompu.', + 'undefinedStyle' => 'Style "%s" non défini', + 'usingInvalidStyle' => 'Tentative de définir un style vide ou invalide' +]; diff --git a/src/Translations/ru.php b/src/Translations/ru.php new file mode 100644 index 0000000..0786f72 --- /dev/null +++ b/src/Translations/ru.php @@ -0,0 +1,55 @@ + + * + * + * Licensed under MIT license. + */ + +return [ + // main + 'arguments' => 'Аргументы', + 'choice' => 'Выбор', + 'choices' => 'Варианты (через запятую)', + 'command' => 'Команда', + 'commandNotFound' => 'Команда %s не найдена', + 'commandSuggestion' => 'Вы имели в виду %s?', + 'commands' => 'Команды', + 'descWithDefault' => '%s [по умолчанию: %s]', + 'helpExample' => '[ОПЦИИ...] [АРГУМЕНТЫ...]', + 'optionHelp' => 'Легенда: <обязательно> [необязательно] вариативные...', + 'options' => 'Опции', + 'promptInvalidValue' => 'Неверное значение. Попробуйте снова!', + 'showHelp' => 'Показать помощь', + 'showHelpFooter' => 'Запустите `<команда> --help` для получения конкретной помощи', + 'showVersion' => 'Показать версию', + 'stackTrace' => 'Трассировка стека', + 'thrownIn' => 'выброшено в', + 'thrownAt' => 'Ha', + 'usage' => 'Использование', + 'usageExamples' => 'Примеры использования', + 'version' => 'версия', + 'verbosityLevel' => 'Уровень подробности', + + // exception + 'argumentVariadic' => 'Только последний аргумент может быть вариативным', + 'badProgram' => 'Не удалось запустить поврежденную программу.', + 'commandAlreadyAdded' => 'Команда "%s" уже добавлена', + 'commandDoesNotExist' => 'Команда "%s" не существует', + 'configOptionMissing' => 'Необходимо указать значение опции конфигурации', + 'invalidTableRowsType' => 'Строки должны быть массивом ассоциативных массивов, %s передано', + 'optionNotRegistered' => 'Опция "%s" не зарегистрирована', + 'parameterAlreadyRegistered' => 'Параметр "%s" уже зарегистрирован', + 'processAlreadyRun' => 'Процесс уже выполняется.', + 'procOpenMissing' => 'Функция proc_open отсутствует в вашей конфигурации PHP.', + 'progressbarCurrentMax' => 'Текущий %d больше общего %d.', + 'progressbarTotalMin' => 'Общий прогресс должен быть больше нуля.', + 'styleInvisible' => 'Встроенные стили не могут быть невидимыми', + 'textRequired' => 'Необходим текст', + 'timeoutOccured' => 'Превышено время ожидания, процесс завершен.', + 'undefinedStyle' => 'Стиль "%s" не определен', + 'usingInvalidStyle' => 'Попытка установить пустой или недопустимый стиль' +]; From b8e089f464a647b0ec43682a3fd2f2a7dba1a81a Mon Sep 17 00:00:00 2001 From: Dimitri Sitchet Tomkeu Date: Fri, 29 Nov 2024 19:53:55 +0100 Subject: [PATCH 05/11] docs: documentation for i18n support --- README.md | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1f40a5b..7bcf735 100644 --- a/README.md +++ b/README.md @@ -820,7 +820,39 @@ Whenever an exception is caught by `Application::handle()`, it will show a beaut ![Exception Preview](https://user-images.githubusercontent.com/2908547/44401057-8b350880-a577-11e8-8ca6-20508d593d98.png "Exception trace") -### Autocompletion +## I18n Support + +**adhocore/cli** also supports internationalisation. This is particularly useful if you are not very comfortable with English or if you are creating a framework or CLI application that could be used by people from a variety of backgrounds. + +By default, all texts generated by our system are in English. But you can easily change them at any time + +```php +\Ahc\Translations\Translator::$locale = 'fr'; +``` + +The system currently supports 5 fully translated languages: + +* English: `en` +* French: `fr` +* Spanish: `es` +* German: `de` +* Russian: `ru` + +If the language you want to use is not supported natively, you can create your own translation file from the main translation file. Simply copy the entire contents of the file `vendor/adhocore/cli/src/Translations/en.php` and translate all the translation values into the desired language. + +After that, you will need to enter the path to the folder where your translation files are stored. +For example, if you have made Chinese (`ch`) and Arabic (`ar`) translations, and they are respectively in the `APP_PATH/translations/ch.php` and `APP_PATH/translations/ar.php` files, you can configure the translator as follows + +```php +\Ahc\Translations\Translator::$translations_dir = APP_PATH . '/translations'; +\Ahc\Translations\Translator::$locale = 'ch'; // or 'ar' +``` + +In the same way, you can override the built-in translations, just create your own file `en.php` or `fr.php`, etc... and indicate the folder in which it is located + +You don't have to translate all the translation keys. If a translation key does not exist in your implementation, the default value will be used. This allows you to translate only the elements you consider important (for example, exception messages do not necessarily need to be translated). + +## Autocompletion Any console applications that are built on top of **adhocore/cli** can entertain autocomplete of commands and options in zsh shell with oh-my-zsh. From 2f187bda11d374b576f27bfbed8cdccd17a61e30 Mon Sep 17 00:00:00 2001 From: Dimitri Sitchet Tomkeu Date: Mon, 2 Dec 2024 18:34:02 +0100 Subject: [PATCH 06/11] refactor: using plain text instead of keys for translations --- src/Application.php | 38 ++++++++++++++++++++++++++++++----- src/Helper/InflectsString.php | 13 +++++------- src/Helper/OutputHelper.php | 16 +++++++-------- src/Helper/Shell.php | 8 ++++---- src/IO/Interactor.php | 4 ++-- src/Input/Command.php | 16 +++++++-------- src/Input/Parameter.php | 2 +- src/Input/Parser.php | 4 ++-- src/Output/Color.php | 8 ++++---- src/Output/ProgressBar.php | 10 +++++---- src/Output/Table.php | 2 +- 11 files changed, 74 insertions(+), 47 deletions(-) diff --git a/src/Application.php b/src/Application.php index 187089c..88b0cb0 100644 --- a/src/Application.php +++ b/src/Application.php @@ -12,6 +12,7 @@ namespace Ahc\Cli; use Ahc\Cli\Exception\InvalidArgumentException; +use Ahc\Cli\Helper\InflectsString; use Ahc\Cli\Helper\OutputHelper; use Ahc\Cli\Input\Command; use Ahc\Cli\IO\Interactor; @@ -28,7 +29,6 @@ use function is_array; use function is_int; use function method_exists; -use function sprintf; /** * A cli application. @@ -40,6 +40,25 @@ */ class Application { + use InflectsString; + + /** + * Locale of CLI. + */ + public static $locale = 'en'; + + /** + * list of translations for each supported locale + * + * @var array + * + * @example + * ```php + * ['locale' => ['key1' => 'value1', 'key2' => 'value2']] + * ``` + */ + public static $locales = []; + /** @var Command[] */ protected array $commands = []; @@ -130,6 +149,15 @@ public function logo(?string $logo = null) return $this; } + public static function addLocale(string $locale, array $texts, bool $default = false) + { + if ($default) { + self::$locale = $locale; + } + + self::$locales[$locale] = $texts; + } + /** * Add a command by its name desc alias etc and return command. */ @@ -161,7 +189,7 @@ public function add(Command $command, string $alias = '', bool $default = false) $this->aliases[$alias] ?? null ) { - throw new InvalidArgumentException(sprintf('Command "%s" already added', $name)); + throw new InvalidArgumentException($this->translate('Command "%s" already added', [$name])); } if ($alias) { @@ -190,7 +218,7 @@ public function add(Command $command, string $alias = '', bool $default = false) public function defaultCommand(string $commandName): self { if (!isset($this->commands[$commandName])) { - throw new InvalidArgumentException(sprintf('Command "%s" does not exist', $commandName)); + throw new InvalidArgumentException($this->translate('Command "%s" does not exist', [$commandName])); } $this->default = $commandName; @@ -386,8 +414,8 @@ public function showHelp(): mixed public function showDefaultHelp(): mixed { $writer = $this->io()->writer(); - $header = "{$this->name}, version {$this->version}"; - $footer = 'Run ` --help` for specific help'; + $header = "{$this->name}, {$this->translate('version')} {$this->version}"; + $footer = $this->translate('Run ` --help` for specific help'); if ($this->logo) { $writer->logo($this->logo, true); diff --git a/src/Helper/InflectsString.php b/src/Helper/InflectsString.php index 73187c4..1d2ea74 100644 --- a/src/Helper/InflectsString.php +++ b/src/Helper/InflectsString.php @@ -11,7 +11,7 @@ namespace Ahc\Cli\Helper; -use Ahc\Cli\Translations\Translator; +use Ahc\Cli\Application; use function lcfirst; use function mb_strwidth; @@ -32,8 +32,6 @@ */ trait InflectsString { - private static ?Translator $translator = null; - /** * Convert a string to camel case. */ @@ -83,12 +81,11 @@ public function substr(string $string, int $start, ?int $length = null): string /** * Translates a message using the translator. */ - public static function translate(string $key, array $args = []): string + public static function translate(string $text, array $args = []): string { - if (self::$translator === null) { - self::$translator = new Translator(); - } + $translations = Application::$locales[Application::$locale] ?? []; + $text = $translations[$text] ?? $text; - return self::$translator->getMessage($key, $args); + return sprintf($text, ...$args); } } diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index f8f1158..2b65ae0 100644 --- a/src/Helper/OutputHelper.php +++ b/src/Helper/OutputHelper.php @@ -79,7 +79,7 @@ public function printTrace(Throwable $e): void $this->writer->colors( "{$eClass} {$e->getMessage()}" . - "({$this->translate('thrownIn')} {$e->getFile()}:{$e->getLine()})" + "({$this->translate('thrown in')} {$e->getFile()}:{$e->getLine()})" ); // @codeCoverageIgnoreStart @@ -89,7 +89,7 @@ public function printTrace(Throwable $e): void } // @codeCoverageIgnoreEnd - $traceStr = "{$this->translate('stackTrace')}:"; + $traceStr = "{$this->translate('Stack Trace')}:"; foreach ($e->getTrace() as $i => $trace) { $trace += ['class' => '', 'type' => '', 'function' => '', 'file' => '', 'line' => '', 'args' => []]; @@ -99,7 +99,7 @@ public function printTrace(Throwable $e): void $traceStr .= " $i) $symbol($args)"; if ('' !== $trace['file']) { $file = realpath($trace['file']); - $traceStr .= " {$this->translate('thrownAt')} $file:{$trace['line']}"; + $traceStr .= " {$this->translate('at')} $file:{$trace['line']}"; } } @@ -187,7 +187,7 @@ protected function showHelp(string $for, array $items, string $header = '', stri $this->writer->help_header($header, true); } - $this->writer->eol()->help_category($this->translate(strtolower($for)) . ':', true); + $this->writer->eol()->help_category($this->translate($for) . ':', true); if (empty($items)) { $this->writer->help_text(' (n/a)', true); @@ -231,7 +231,7 @@ public function showUsage(string $usage): self $usage = str_replace('$0', $_SERVER['argv'][0] ?? '[cmd]', $usage); if (!str_contains($usage, ' ## ')) { - $this->writer->eol()->help_category($this->translate('usageExamples') . ':', true)->colors($usage)->eol(); + $this->writer->eol()->help_category($this->translate('Usage Examples') . ':', true)->colors($usage)->eol(); return $this; } @@ -248,7 +248,7 @@ public function showUsage(string $usage): self return str_pad('# ', $maxlen - array_shift($lines), ' ', STR_PAD_LEFT); }, $usage); - $this->writer->eol()->help_category($this->translate('usageExamples') . ':', true)->colors($usage)->eol(); + $this->writer->eol()->help_category($this->translate('Usage Examples') . ':', true)->colors($usage)->eol(); return $this; } @@ -263,11 +263,11 @@ public function showCommandNotFound(string $attempted, array $available): self } } - $this->writer->error($this->translate('commandNotFound', [$attempted]), true); + $this->writer->error($this->translate('Command %s not found', [$attempted]), true); if ($closest) { asort($closest); $closest = key($closest); - $this->writer->bgRed($this->translate('commandSuggestion', [$closest]), true); + $this->writer->bgRed($this->translate('Did you mean %s?', [$closest]), true); } return $this; diff --git a/src/Helper/Shell.php b/src/Helper/Shell.php index 8720b4a..251c12a 100644 --- a/src/Helper/Shell.php +++ b/src/Helper/Shell.php @@ -101,7 +101,7 @@ public function __construct(protected string $command, protected ?string $input { // @codeCoverageIgnoreStart if (!function_exists('proc_open')) { - throw new RuntimeException($this->translate('procOpenMissing')); + throw new RuntimeException($this->translate('Required proc_open could not be found in your PHP setup.')); } // @codeCoverageIgnoreEnd @@ -183,7 +183,7 @@ protected function checkTimeout(): void if ($executionDuration > $this->processTimeout) { $this->kill(); - throw new RuntimeException($this->translate('timeoutOccured')); + throw new RuntimeException($this->translate('Timeout occurred, process terminated.')); } // @codeCoverageIgnoreStart } @@ -218,7 +218,7 @@ public function setOptions( public function execute(bool $async = false, ?array $stdin = null, ?array $stdout = null, ?array $stderr = null): self { if ($this->isRunning()) { - throw new RuntimeException($this->translate('processAlreadyRun')); + throw new RuntimeException($this->translate('Process is already running.')); } $this->descriptors = $this->prepareDescriptors($stdin, $stdout, $stderr); @@ -236,7 +236,7 @@ public function execute(bool $async = false, ?array $stdin = null, ?array $stdou // @codeCoverageIgnoreStart if (!is_resource($this->process)) { - throw new RuntimeException($this->translate('badProgram')); + throw new RuntimeException($this->translate('Bad program could not be started.')); } // @codeCoverageIgnoreEnd diff --git a/src/IO/Interactor.php b/src/IO/Interactor.php index 738c9cc..0e8b5f0 100644 --- a/src/IO/Interactor.php +++ b/src/IO/Interactor.php @@ -315,7 +315,7 @@ public function choices(string $text, array $choices, $default = null, bool $cas */ public function prompt(string $text, $default = null, ?callable $fn = null, int $retry = 3): mixed { - $error = $this->translate('promptInvalidValue'); + $error = $this->translate('Invalid value. Please try again!'); $hidden = func_get_args()[4] ?? false; $readFn = ['read', 'readHidden'][(int) $hidden]; @@ -373,7 +373,7 @@ protected function listOptions(array $choices, $default = null, bool $multi = fa $this->writer->eol()->choice(str_pad(" [$choice]", $maxLen + 6))->answer($desc); } - $label = $this->translate($multi ? 'choices' : 'choice'); + $label = $this->translate($multi ? 'Choices (comma separated)' : 'Choice'); $this->writer->eol()->question($label); diff --git a/src/Input/Command.php b/src/Input/Command.php index a2dda09..2257d6d 100644 --- a/src/Input/Command.php +++ b/src/Input/Command.php @@ -83,9 +83,9 @@ public function __construct( */ protected function defaults(): self { - $this->option('-h, --help', $this->translate('showHelp'))->on([$this, 'showHelp']); - $this->option('-V, --version', $this->translate('showVersion'))->on([$this, 'showVersion']); - $this->option('-v, --verbosity', $this->translate('verbosityLevel'), null, 0)->on( + $this->option('-h, --help', $this->translate('Show help'))->on([$this, 'showHelp']); + $this->option('-V, --version', $this->translate('Show version'))->on([$this, 'showVersion']); + $this->option('-v, --verbosity', $this->translate('Verbosity level'), null, 0)->on( fn () => $this->set('verbosity', ($this->verbosity ?? 0) + 1) && false ); @@ -196,7 +196,7 @@ public function argument(string $raw, string $desc = '', $default = null): self $argument = new Argument($raw, $desc, $default); if ($this->_argVariadic) { - throw new InvalidParameterException($this->translate('argumentVariadic')); + throw new InvalidParameterException($this->translate('Only last argument can be variadic')); } if ($argument->variadic()) { @@ -303,7 +303,7 @@ protected function handleUnknown(string $arg, ?string $value = null): mixed // Has some value, error! if ($values) { - throw new RuntimeException($this->translate('optionNotRegistered', [$arg])); + throw new RuntimeException($this->translate('Option "%s" not registered', [$arg])); } // Has no value, show help! @@ -356,13 +356,13 @@ public function showDefaultHelp(): mixed $io->logo($logo, true); } - $io->help_header("{$this->translate('command')} {$this->_name}, {$this->translate('version')} {$this->_version}", true)->eol(); + $io->help_header("{$this->translate('Command')} {$this->_name}, {$this->translate('version')} {$this->_version}", true)->eol(); $io->help_summary($this->_desc, true)->eol(); - $io->help_text("{$this->translate('usage')}: ")->help_example("{$this->_name} {$this->translate('helpExample')}", true); + $io->help_text("{$this->translate('Usage')}: ")->help_example("{$this->_name} {$this->translate('[OPTIONS...] [ARGUMENTS...]')}", true); $helper ->showArgumentsHelp($this->allArguments()) - ->showOptionsHelp($this->allOptions(), '', $this->translate('optionHelp')); + ->showOptionsHelp($this->allOptions(), '', $this->translate('Legend: [optional] variadic...')); if ($this->_usage) { $helper->showUsage($this->_usage); diff --git a/src/Input/Parameter.php b/src/Input/Parameter.php index 791155b..e443d31 100644 --- a/src/Input/Parameter.php +++ b/src/Input/Parameter.php @@ -83,7 +83,7 @@ public function desc(bool $withDefault = false): string return $this->desc; } - return ltrim($this->translate('descWithDefault', [$this->desc, json_encode($this->default)])); + return ltrim($this->translate('%s [default: %s]', [$this->desc, json_encode($this->default)])); } /** diff --git a/src/Input/Parser.php b/src/Input/Parser.php index 2b0c5f3..f11b973 100644 --- a/src/Input/Parser.php +++ b/src/Input/Parser.php @@ -225,7 +225,7 @@ protected function validate(): void } throw new RuntimeException( - sprintf('%s "%s" is required', $label, $name) + $this->translate('%s "%s" is required', [$label, $name]) ); } } @@ -268,7 +268,7 @@ protected function ifAlreadyRegistered(Parameter $param): void { if ($this->registered($param->attributeName())) { throw new InvalidParameterException($this->translate( - 'parameterAlreadyRegistered', + 'The parameter "%s" is already registered', [$param instanceof Option ? $param->long() : $param->name()] )); } diff --git a/src/Output/Color.php b/src/Output/Color.php index b6560d7..160f4a2 100644 --- a/src/Output/Color.php +++ b/src/Output/Color.php @@ -194,12 +194,12 @@ public static function style(string $name, array $style): void $style = array_intersect_key($style, $allow); if (empty($style)) { - throw new InvalidArgumentException(self::translate('usingInvalidStyle')); + throw new InvalidArgumentException(self::translate('Trying to set empty or invalid style')); } $invisible = (isset($style['bg']) && isset($style['fg']) && $style['bg'] === $style['fg']); if ($invisible && method_exists(static::class, $name)) { - throw new InvalidArgumentException(self::translate('styleInvisible')); + throw new InvalidArgumentException(self::translate('Built-in styles cannot be invisible')); } static::$styles[$name] = $style; @@ -216,7 +216,7 @@ public static function style(string $name, array $style): void public function __call(string $name, array $arguments): string { if (!isset($arguments[0])) { - throw new InvalidArgumentException($this->translate('textRequired')); + throw new InvalidArgumentException($this->translate('Text required')); } [$name, $text, $style] = $this->parseCall($name, $arguments); @@ -231,7 +231,7 @@ public function __call(string $name, array $arguments): string } if (!method_exists($this, $name)) { - throw new InvalidArgumentException($this->translate('undefinedStyle', [$name])); + throw new InvalidArgumentException($this->translate('Style "%s" not defined', [$name])); } return $this->{$name}($text, $style); diff --git a/src/Output/ProgressBar.php b/src/Output/ProgressBar.php index 259a971..4286677 100644 --- a/src/Output/ProgressBar.php +++ b/src/Output/ProgressBar.php @@ -136,7 +136,7 @@ public function option(string|array $key, ?string $value = null): self { if (is_string($key)) { if (empty($value)) { - throw new UnexpectedValueException($this->translate('configOptionMissing')); + throw new UnexpectedValueException($this->translate('Configuration option value is required')); } $key = [$key => $value]; @@ -166,11 +166,13 @@ public function current(int $current, string $label = '') { if ($this->total == 0) { // Avoid dividing by 0 - throw new UnexpectedValueException($this->translate('progressbarTotalMin')); + throw new UnexpectedValueException($this->translate('The progress total must be greater than zero.')); } - + if ($current > $this->total) { - throw new UnexpectedValueException($this->translate('progressbarCurrentMax', [$current, $this->total])); + throw new UnexpectedValueException( + $this->translate('The current (%d) is greater than the total (%d).', [$current, $this->total]) + ); } $this->drawProgressBar($current, $label); diff --git a/src/Output/Table.php b/src/Output/Table.php index 9b651f6..1837389 100644 --- a/src/Output/Table.php +++ b/src/Output/Table.php @@ -106,7 +106,7 @@ protected function normalize(array $rows): array if (!is_array($head)) { throw new InvalidArgumentException( - $this->translate('invalidTableRowsType', [gettype($head)]) + $this->translate('Rows must be array of assoc arrays, %s given', [gettype($head)]) ); } From 16f8b48335c097b4b1ab480e9c5f410d4317bd19 Mon Sep 17 00:00:00 2001 From: Dimitri Sitchet Tomkeu Date: Mon, 2 Dec 2024 18:36:53 +0100 Subject: [PATCH 07/11] refactor: deletion of the `Translator` as it is no longer in use --- src/Translations/Translator.php | 105 ------------- src/Translations/de.php | 55 ------- src/Translations/en.php | 55 ------- src/Translations/es.php | 55 ------- src/Translations/fr.php | 55 ------- src/Translations/ru.php | 55 ------- tests/Translations/TranslatorTest.php | 217 -------------------------- 7 files changed, 597 deletions(-) delete mode 100644 src/Translations/Translator.php delete mode 100644 src/Translations/de.php delete mode 100644 src/Translations/en.php delete mode 100644 src/Translations/es.php delete mode 100644 src/Translations/fr.php delete mode 100644 src/Translations/ru.php delete mode 100644 tests/Translations/TranslatorTest.php diff --git a/src/Translations/Translator.php b/src/Translations/Translator.php deleted file mode 100644 index b9f219d..0000000 --- a/src/Translations/Translator.php +++ /dev/null @@ -1,105 +0,0 @@ - - * - * - * Licensed under MIT license. - */ - -namespace Ahc\Cli\Translations; - -use function array_merge; -use function is_file; -use function sprintf; - -/** - * Translation support of CLI - * - * @author Dimitri Sitchet Tomkeu - * @license MIT - * - * @link https://github.com/adhocore/cli - */ -class Translator -{ - /** - * Locale of CLI. - */ - public static string $locale = 'en'; - - /** - * Folder in which the translation files are to be searched. - */ - public static string $translations_dir = __DIR__; - - /** - * Stores translations recovered from files for faster recovery when used a second time. - * - * @var array - * - * @example - * ```php - * ['locale' => ['key1' => 'value1', 'key2' => 'value2']] - * ``` - */ - private static array $translations = []; - - /** - * Constructor. - */ - public function __construct() - { - $this->loadDefaultTranslations(); - } - - /** - * Retrieves a translated string based on the provided key and optional arguments. - * - * @param string $key The key of the translation string. - * @param array $args Optional arguments to replace placeholders in the translation string. - */ - public function getMessage(string $key, array $args = []): string - { - if ($key === '') { - return ''; - } - - $this->loadTranslations(); - - $message = self::$translations[self::$locale][$key] ?? ''; - - return $args === [] ? $message : sprintf($message, ...$args); - } - - /** - * Loads translations for the current locale if they haven't been loaded yet. - * - * This method checks if translations for the current locale exist in the static - * $translations array. If not, it attempts to load them from a PHP file in the - * translations directory. The file name is expected to match the locale name. - * If the file exists, its contents are merged with the default translations. - */ - protected function loadTranslations(): void - { - if (!isset(self::$translations[self::$locale])) { - $path = self::$translations_dir . '/' . self::$locale . '.php'; - - $translations = is_file($path) ? require $path : []; - - self::$translations[self::$locale] = array_merge(self::$translations['__default__'], $translations); - } - } - - /** - * Loads the default translations for the application. - */ - protected function loadDefaultTranslations(): void - { - if (!isset(self::$translations['__default__'])) { - self::$translations['__default__'] = require __DIR__ . '/en.php'; - } - } -} diff --git a/src/Translations/de.php b/src/Translations/de.php deleted file mode 100644 index 96d367a..0000000 --- a/src/Translations/de.php +++ /dev/null @@ -1,55 +0,0 @@ - - * - * - * Licensed under MIT license. - */ - -return [ - // main - 'arguments' => 'Argumente', - 'choice' => 'Wahl', - 'choices' => 'Wahlmöglichkeiten (durch Komma getrennt)', - 'command' => 'Befehl', - 'commandNotFound' => 'Befehl %s nicht gefunden', - 'commandSuggestion' => 'Meinten Sie %s?', - 'commands' => 'Befehle', - 'descWithDefault' => '%s [Standard: %s]', - 'helpExample' => '[OPTIONEN...] [ARGUMENTE...]', - 'optionHelp' => 'Legende: [optional] variadisch...', - 'options' => 'Optionen', - 'promptInvalidValue' => 'Ungültiger Wert. Versuchen Sie es erneut!', - 'showHelp' => 'Hilfe anzeigen', - 'showHelpFooter' => 'Führen Sie ` --help` für spezifische Hilfe aus', - 'showVersion' => 'Version anzeigen', - 'stackTrace' => 'Stack Trace', - 'thrownIn' => 'geworfen in', - 'thrownAt' => 'bei', - 'usage' => 'Verwendung', - 'usageExamples' => 'Verwendungsbeispiele', - 'version' => 'Version', - 'verbosityLevel' => 'Verbosity-Level', - - // exceptions - 'argumentVariadic' => 'Nur das letzte Argument kann variadisch sein', - 'badProgram' => 'Das fehlerhafte Programm konnte nicht gestartet werden.', - 'commandAlreadyAdded' => 'Befehl "%s" wurde bereits hinzugefügt', - 'commandDoesNotExist' => 'Befehl "%s" existiert nicht', - 'configOptionMissing' => 'Der Wert der Konfigurationsoption wird benötigt', - 'invalidTableRowsType' => 'Zeilen müssen ein Array von assoziativen Arrays sein, %s gegeben', - 'optionNotRegistered' => 'Option "%s" nicht registriert', - 'parameterAlreadyRegistered' => 'Der Parameter "%s" ist bereits registriert', - 'processAlreadyRun' => 'Der Prozess läuft bereits.', - 'procOpenMissing' => 'Die Funktion proc_open fehlt in Ihrer PHP-Konfiguration.', - 'progressbarCurrentMax' => 'Der aktuelle (%d) ist größer als der Gesamtwert (%d).', - 'progressbarTotalMin' => 'Der Gesamtwert der Fortschrittsanzeige muss größer als null sein.', - 'styleInvisible' => 'Eingebaute Stile können nicht unsichtbar sein', - 'textRequired' => 'Text erforderlich', - 'timeoutOccured' => 'Zeitüberschreitung, Prozess beendet.', - 'undefinedStyle' => 'Stil "%s" nicht definiert', - 'usingInvalidStyle' => 'Versuch, einen leeren oder ungültigen Stil festzulegen' -]; diff --git a/src/Translations/en.php b/src/Translations/en.php deleted file mode 100644 index 8a0e9ea..0000000 --- a/src/Translations/en.php +++ /dev/null @@ -1,55 +0,0 @@ - - * - * - * Licensed under MIT license. - */ - -return [ - // main - 'arguments' => 'Arguments', - 'choice' => 'Choice', - 'choices' => 'Choices (comma separated)', - 'command' => 'Command', - 'commandNotFound' => 'Command %s not found', - 'commandSuggestion' => 'Did you mean %s?', - 'commands' => 'Commands', - 'descWithDefault' => '%s [default: %s]', - 'helpExample' => '[OPTIONS...] [ARGUMENTS...]', - 'optionHelp' => 'Legend: [optional] variadic...', - 'options' => 'Options', - 'promptInvalidValue' => 'Invalid value. Please try again!', - 'showHelp' => 'Show help', - 'showHelpFooter' => 'Run ` --help` for specific help', - 'showVersion' => 'Show version', - 'stackTrace' => 'Stack Trace', - 'thrownIn' => 'thrown in', - 'thrownAt' => 'at', - 'usage' => 'Usage', - 'usageExamples' => 'Usage Examples', - 'version' => 'version', - 'verbosityLevel' => 'Verbosity level', - - // exceptions - 'argumentVariadic' => 'Only last argument can be variadic', - 'badProgram' => 'Bad program could not be started.', - 'commandAlreadyAdded' => 'Command "%s" already added', - 'commandDoesNotExist' => 'Command "%s" does not exist', - 'configOptionMissing' => 'Configuration option value is required', - 'invalidTableRowsType' => 'Rows must be array of assoc arrays, %s given', - 'optionNotRegistered' => 'Option "%s" not registered', - 'parameterAlreadyRegistered' => 'The parameter "%s" is already registered', - 'processAlreadyRun' => 'Process is already running.', - 'procOpenMissing' => 'Required proc_open could not be found in your PHP setup.', - 'progressbarCurrentMax' => 'The current (%d) is greater than the total (%d).', - 'progressbarTotalMin' => 'The progress total must be greater than zero.', - 'styleInvisible' => 'Built-in styles cannot be invisible', - 'textRequired' => 'Text required', - 'timeoutOccured' => 'Timeout occurred, process terminated.', - 'undefinedStyle' => 'Style "%s" not defined', - 'usingInvalidStyle' => 'Trying to set empty or invalid style', -]; diff --git a/src/Translations/es.php b/src/Translations/es.php deleted file mode 100644 index 1b3ff1c..0000000 --- a/src/Translations/es.php +++ /dev/null @@ -1,55 +0,0 @@ - - * - * - * Licensed under MIT license. - */ - -return [ - // main - 'arguments' => 'Argumentos', - 'choice' => 'Elección', - 'choices' => 'Opciones (separadas por coma)', - 'command' => 'Comando', - 'commandNotFound' => 'Comando %s no encontrado', - 'commandSuggestion' => '¿Quiso decir %s?', - 'commands' => 'Comandos', - 'descWithDefault' => '%s [por defecto: %s]', - 'helpExample' => '[OPCIONES...] [ARGUMENTOS...]', - 'optionHelp' => 'Leyenda: [opcional] variadic...', - 'options' => 'Opciones', - 'promptInvalidValue' => 'Valor no válido. ¡Intente nuevamente!', - 'showHelp' => 'Mostrar ayuda', - 'showHelpFooter' => 'Ejecute ` --help` para ayuda específica', - 'showVersion' => 'Mostrar versión', - 'stackTrace' => 'Rastro de pila', - 'thrownIn' => 'lanzado en', - 'thrownAt' => 'en', - 'usage' => 'Uso', - 'usageExamples' => 'Ejemplos de uso', - 'version' => 'versión', - 'verbosityLevel' => 'Nivel de verbosidad', - - // translations - 'argumentVariadic' => 'Solo el último argumento puede ser variádico', - 'badProgram' => 'No se pudo iniciar el programa defectuoso.', - 'commandAlreadyAdded' => 'El comando "%s" ya fue añadido', - 'commandDoesNotExist' => 'El comando "%s" no existe', - 'configOptionMissing' => 'Se requiere el valor de la opción de configuración', - 'invalidTableRowsType' => 'Las filas deben ser un array de arrays asociativos, %s dado', - 'optionNotRegistered' => 'La opción "%s" no está registrada', - 'parameterAlreadyRegistered' => 'El parámetro "%s" ya está registrado', - 'processAlreadyRun' => 'El proceso ya está en ejecución.', - 'procOpenMissing' => 'La función proc_open no está disponible en su configuración PHP.', - 'progressbarCurrentMax' => 'El %d actual es mayor que el total %d.', - 'progressbarTotalMin' => 'El total de la barra de progreso debe ser mayor que cero.', - 'styleInvisible' => 'Los estilos incorporados no pueden ser invisibles', - 'textRequired' => 'Texto requerido', - 'timeoutOccured' => 'Tiempo agotado, proceso terminado.', - 'undefinedStyle' => 'Estilo "%s" no definido', - 'usingInvalidStyle' => 'Intentando establecer un estilo vacío o inválido' -]; diff --git a/src/Translations/fr.php b/src/Translations/fr.php deleted file mode 100644 index d420f32..0000000 --- a/src/Translations/fr.php +++ /dev/null @@ -1,55 +0,0 @@ - - * - * - * Licensed under MIT license. - */ - -return [ - // main - 'arguments' => 'Arguments', - 'choice' => 'Choix', - 'choices' => 'Choix (séparés par des virgules)', - 'command' => 'Commande', - 'commandNotFound' => 'Commande %s non trouvée', - 'commandSuggestion' => 'Vouliez-vous dire %s ?', - 'commands' => 'Commandes', - 'descWithDefault' => '%s [par défaut : %s]', - 'helpExample' => '[OPTIONS...] [ARGUMENTS...]', - 'optionHelp' => 'Légende : [optionnel] variadique...', - 'options' => 'Options', - 'promptInvalidValue' => 'Valeur invalide. Essayez à nouveau!', - 'showHelp' => 'Afficher l\'aide', - 'showHelpFooter' => 'Exécutez ` --help` pour de l\'aide spécifique', - 'showVersion' => 'Afficher la version', - 'stackTrace' => 'Trace de la pile', - 'thrownIn' => 'levée dans', - 'thrownAt' => 'à', - 'usage' => 'Utilisation', - 'usageExamples' => 'Exemples d\'utilisation', - 'version' => 'version', - 'verbosityLevel' => 'Niveau de verbosité', - - // exceptions - 'argumentVariadic' => 'Seul le dernier argument peut être variadique', - 'badProgram' => 'Le programme défectueux n\'a pas pu être démarré.', - 'commandAlreadyAdded' => 'La commande "%s" a déjà été ajoutée', - 'commandDoesNotExist' => 'La commande "%s" n\'existe pas', - 'configOptionMissing' => 'La valeur de l\'option de configuration est requise', - 'invalidTableRowsType' => 'Les lignes doivent être un tableau de tableaux associatifs, %s donné', - 'optionNotRegistered' => 'L\'option "%s" n\'est pas enregistrée', - 'parameterAlreadyRegistered' => 'Le paramètre "%s" est déjà enregistré', - 'processAlreadyRun' => 'Le processus est déjà en cours.', - 'procOpenMissing' => 'La fonction proc_open est manquante dans votre configuration PHP.', - 'progressbarCurrentMax' => 'Le %d actuel est supérieur au total %d.', - 'progressbarTotalMin' => 'Le total de la barre de progression doit être supérieur à zéro.', - 'styleInvisible' => 'Les styles intégrés ne peuvent pas être invisibles', - 'textRequired' => 'Texte requis', - 'timeoutOccured' => 'Délai dépassé, processus interrompu.', - 'undefinedStyle' => 'Style "%s" non défini', - 'usingInvalidStyle' => 'Tentative de définir un style vide ou invalide' -]; diff --git a/src/Translations/ru.php b/src/Translations/ru.php deleted file mode 100644 index 0786f72..0000000 --- a/src/Translations/ru.php +++ /dev/null @@ -1,55 +0,0 @@ - - * - * - * Licensed under MIT license. - */ - -return [ - // main - 'arguments' => 'Аргументы', - 'choice' => 'Выбор', - 'choices' => 'Варианты (через запятую)', - 'command' => 'Команда', - 'commandNotFound' => 'Команда %s не найдена', - 'commandSuggestion' => 'Вы имели в виду %s?', - 'commands' => 'Команды', - 'descWithDefault' => '%s [по умолчанию: %s]', - 'helpExample' => '[ОПЦИИ...] [АРГУМЕНТЫ...]', - 'optionHelp' => 'Легенда: <обязательно> [необязательно] вариативные...', - 'options' => 'Опции', - 'promptInvalidValue' => 'Неверное значение. Попробуйте снова!', - 'showHelp' => 'Показать помощь', - 'showHelpFooter' => 'Запустите `<команда> --help` для получения конкретной помощи', - 'showVersion' => 'Показать версию', - 'stackTrace' => 'Трассировка стека', - 'thrownIn' => 'выброшено в', - 'thrownAt' => 'Ha', - 'usage' => 'Использование', - 'usageExamples' => 'Примеры использования', - 'version' => 'версия', - 'verbosityLevel' => 'Уровень подробности', - - // exception - 'argumentVariadic' => 'Только последний аргумент может быть вариативным', - 'badProgram' => 'Не удалось запустить поврежденную программу.', - 'commandAlreadyAdded' => 'Команда "%s" уже добавлена', - 'commandDoesNotExist' => 'Команда "%s" не существует', - 'configOptionMissing' => 'Необходимо указать значение опции конфигурации', - 'invalidTableRowsType' => 'Строки должны быть массивом ассоциативных массивов, %s передано', - 'optionNotRegistered' => 'Опция "%s" не зарегистрирована', - 'parameterAlreadyRegistered' => 'Параметр "%s" уже зарегистрирован', - 'processAlreadyRun' => 'Процесс уже выполняется.', - 'procOpenMissing' => 'Функция proc_open отсутствует в вашей конфигурации PHP.', - 'progressbarCurrentMax' => 'Текущий %d больше общего %d.', - 'progressbarTotalMin' => 'Общий прогресс должен быть больше нуля.', - 'styleInvisible' => 'Встроенные стили не могут быть невидимыми', - 'textRequired' => 'Необходим текст', - 'timeoutOccured' => 'Превышено время ожидания, процесс завершен.', - 'undefinedStyle' => 'Стиль "%s" не определен', - 'usingInvalidStyle' => 'Попытка установить пустой или недопустимый стиль' -]; diff --git a/tests/Translations/TranslatorTest.php b/tests/Translations/TranslatorTest.php deleted file mode 100644 index ec45c0a..0000000 --- a/tests/Translations/TranslatorTest.php +++ /dev/null @@ -1,217 +0,0 @@ -translator = new Translator(); - } - - public static function setUpBeforeClass(): void - { - self::$baseTranslationsDir = Translator::$translations_dir; - } - - protected function tearDown(): void - { - Translator::$translations_dir = self::$baseTranslationsDir; - } - - public function test_get_message_returns_empty_string_for_empty_key(): void - { - $result = $this->translator->getMessage(''); - $this->assertSame('', $result); - } - - public function test_default_translations_loaded_on_instantiation(): void - { - $reflector = new ReflectionClass(Translator::class); - $translationsProperty = $reflector->getProperty('translations'); - $translationsProperty->setAccessible(true); - - $translations = $translationsProperty->getValue(); - - $this->assertArrayHasKey('__default__', $translations); - $this->assertNotEmpty($translations['__default__']); - $this->assertSame(require self::$baseTranslationsDir . '/en.php', $translations['__default__']); - } - - public function test_get_message_returns_correct_translation_for_default_locale(): void - { - $key = 'badProgram'; - $expectedTranslation = 'Bad program could not be started.'; - - $result = $this->translator->getMessage($key); - - $this->assertSame($expectedTranslation, $result); - } - - public function test_get_message_replaces_placeholders_with_arguments(): void - { - $key = 'test_placeholder'; - $message = 'Hello, %s! You are %d years old.'; - $args = ['John', 30]; - $expectedResult = 'Hello, John! You are 30 years old.'; - - // Set up a mock translation - $reflector = new ReflectionClass(Translator::class); - $translationsProperty = $reflector->getProperty('translations'); - $translationsProperty->setAccessible(true); - $translationsProperty->setValue([ - 'en' => [$key => $message] - ]); - - $result = $this->translator->getMessage($key, $args); - - $this->assertSame($expectedResult, $result); - } - - public function test_get_message_returns_empty_string_for_non_existent_key(): void - { - $nonExistentKey = 'non_existent_key'; - $result = $this->translator->getMessage($nonExistentKey); - $this->assertSame('', $result); - } - - public function test_load_translations_for_non_default_locale(): void - { - $customLocale = 'french'; // dont use `fr` because it's a true locale and file exist - Translator::$locale = $customLocale; - - // Create a mock translation file for the custom locale - $mockTranslations = [ - 'test_key' => 'Test en français', - ]; - $mockFilePath = Translator::$translations_dir . '/' . $customLocale . '.php'; - file_put_contents($mockFilePath, 'getMessage('test_key'); - - $this->assertSame('Test en français', $result); - - // Clean up - unlink($mockFilePath); - Translator::$locale = 'en'; - } - - public function test_merge_custom_translations_with_default_translations(): void - { - $customLocale = 'fr'; - Translator::$locale = $customLocale; - Translator::$translations_dir = __DIR__; - - // Create mock custom translations - $customTranslations = [ - 'usage' => 'Utilisation', - ]; - - $mockCustomPath = Translator::$translations_dir . '/' . $customLocale . '.php'; - file_put_contents($mockCustomPath, 'assertSame('Utilisation', $translator->getMessage('usage')); - $this->assertSame('Usage Examples', $translator->getMessage('usageExamples')); - - unlink($mockCustomPath); - Translator::$locale = 'en'; - } - - public function test_use_cached_translations_when_requesting_same_locale_multiple_times(): void - { - $customLocale = 'fr'; - Translator::$locale = $customLocale; - Translator::$translations_dir = __DIR__; - - // Create a mock translation file for the custom locale - $mockTranslations = [ - 'test_key' => 'Test en français', - ]; - $mockFilePath = Translator::$translations_dir . '/' . $customLocale . '.php'; - file_put_contents($mockFilePath, 'getMessage('test_key'); - $this->assertSame('Test en français', $result1); - - // Modify the mock file to ensure we're using cached translations - file_put_contents($mockFilePath, ' 'Modified test'], true) . ';'); - - // Second call should use cached translations - $result2 = $translator->getMessage('test_key'); - $this->assertSame('Test en français', $result2); - - // Clean up - unlink($mockFilePath); - Translator::$locale = 'en'; - } - - public function test_handle_non_existent_translation_file(): void - { - $nonExistentLocale = 'xx'; - Translator::$locale = $nonExistentLocale; - - // Ensure the translation file doesn't exist - $nonExistentPath = Translator::$translations_dir . '/' . $nonExistentLocale . '.php'; - $this->assertFileDoesNotExist($nonExistentPath); - - $translator = new Translator(); - - // Test with a key that exists in the default translations - $defaultKey = 'badProgram'; - $expectedDefaultTranslation = 'Bad program could not be started.'; - $result = $translator->getMessage($defaultKey); - $this->assertSame($expectedDefaultTranslation, $result); - - // Test with a key that doesn't exist in the default translations - $nonExistentKey = 'non_existent_key'; - $result = $translator->getMessage($nonExistentKey); - $this->assertSame('', $result); - - // Reset the locale - Translator::$locale = 'en'; - } - - public function test_change_locale_after_initial_translations_loaded(): void - { - Translator::$translations_dir = __DIR__; - - // Set up initial locale and translations - Translator::$locale = 'en'; - $translator = new Translator(); - $initialMessage = $translator->getMessage('badProgram'); - - // Change locale to a new one - $newLocale = 'fr'; - Translator::$locale = $newLocale; - - // Create mock translations for the new locale - $mockTranslations = [ - 'badProgram' => 'Mauvais programme', - ]; - $mockFilePath = Translator::$translations_dir . '/' . $newLocale . '.php'; - file_put_contents($mockFilePath, 'getMessage('badProgram'); - - // Assert that the messages are different - $this->assertNotSame($initialMessage, $newMessage); - $this->assertSame('Mauvais programme', $newMessage); - - // Clean up - unlink($mockFilePath); - Translator::$locale = 'en'; - } -} From c8dce0e9a4cc8864e419839a0e9101b4634408d1 Mon Sep 17 00:00:00 2001 From: Dimitri Sitchet Tomkeu Date: Mon, 2 Dec 2024 18:59:03 +0100 Subject: [PATCH 08/11] test: app translate --- tests/ApplicationTest.php | 17 +++++++++++++++++ tests/Helper/InflectsStringTest.php | 25 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/tests/ApplicationTest.php b/tests/ApplicationTest.php index 4283d0f..2b70a6f 100644 --- a/tests/ApplicationTest.php +++ b/tests/ApplicationTest.php @@ -326,6 +326,23 @@ public function test_on_exception() $app->handle(['test', 'cmd']); } + public function test_app_translated() + { + $app = $this->newApp('test'); + $app->addLocale('fr', [ + 'Show version' => 'Afficher la version', + 'Verbosity level' => 'Niveau de verbocité', + '%s [default: %s]' => '%s [par défaut: %s]', + ], true); + $app->command('rmdir'); + + $app->handle(['test', 'rmdir', '--help']); + $o = file_get_contents(static::$ou); + + $this->assertStringContainsString('Afficher la version', $o); + $this->assertStringContainsString('Niveau de verbocité [par défaut: 0]', $o); + } + protected function newApp(string $name, string $version = '') { $app = new Application($name, $version ?: '0.0.1', fn () => false); diff --git a/tests/Helper/InflectsStringTest.php b/tests/Helper/InflectsStringTest.php index c472548..9eb3220 100644 --- a/tests/Helper/InflectsStringTest.php +++ b/tests/Helper/InflectsStringTest.php @@ -11,6 +11,7 @@ namespace Ahc\Cli\Test\Helper; +use Ahc\Cli\Application; use Ahc\Cli\Helper\InflectsString; use PHPUnit\Framework\TestCase; @@ -31,4 +32,28 @@ public function test_to_words() $this->assertSame('The Long Name', $this->toWords('--the_long-name')); $this->assertSame('A BC', $this->toWords('a_bC')); } + + public function test_default_translate(): void + { + $this->assertSame('Show version', $this->translate('Show version')); + $this->assertSame('Verbosity level [default: 0]', $this->translate('%s [default: %s]', ['Verbosity level', 0])); + $this->assertSame('Command "rmdir" already added', $this->translate('Command "%s" already added', ['rmdir'])); + } + + public function test_custom_translations(): void + { + Application::addLocale('fr', [ + 'Show version' => 'Afficher la version', + '%s [default: %s]' => '%s [par défaut: %s]', + 'Command "%s" already added' => 'La commande "%s" a déjà été ajoutée' + ], true); + + + $this->assertSame('Afficher la version', $this->translate('Show version')); + $this->assertSame('Verbosity level [par défaut: 0]', $this->translate('%s [default: %s]', ['Verbosity level', 0])); + $this->assertSame('La commande "rmdir" a déjà été ajoutée', $this->translate('Command "%s" already added', ['rmdir'])); + + // untranslated key + $this->assertSame('Show help', $this->translate('Show help')); + } } From a0845f6f0ea20778f94be1c75e634e14290eaa10 Mon Sep 17 00:00:00 2001 From: Dimitri Sitchet Tomkeu Date: Mon, 2 Dec 2024 19:42:51 +0100 Subject: [PATCH 09/11] docs: update documentation of i18n support --- README.md | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 7bcf735..4c3dd1c 100644 --- a/README.md +++ b/README.md @@ -824,33 +824,23 @@ Whenever an exception is caught by `Application::handle()`, it will show a beaut **adhocore/cli** also supports internationalisation. This is particularly useful if you are not very comfortable with English or if you are creating a framework or CLI application that could be used by people from a variety of backgrounds. -By default, all texts generated by our system are in English. But you can easily change them at any time +By default, all the texts generated by our system are in English. But you can easily modify them by defining your translations as follows ```php -\Ahc\Translations\Translator::$locale = 'fr'; +\Ahc\Application::addLocale('fr', [ + 'Only last argument can be variadic' => 'Seul le dernier argument peut être variadique', +], true); ``` -The system currently supports 5 fully translated languages: - -* English: `en` -* French: `fr` -* Spanish: `es` -* German: `de` -* Russian: `ru` - -If the language you want to use is not supported natively, you can create your own translation file from the main translation file. Simply copy the entire contents of the file `vendor/adhocore/cli/src/Translations/en.php` and translate all the translation values into the desired language. - -After that, you will need to enter the path to the folder where your translation files are stored. -For example, if you have made Chinese (`ch`) and Arabic (`ar`) translations, and they are respectively in the `APP_PATH/translations/ch.php` and `APP_PATH/translations/ar.php` files, you can configure the translator as follows +You can also change the default English text to make the description more explicit if you wish. ```php -\Ahc\Translations\Translator::$translations_dir = APP_PATH . '/translations'; -\Ahc\Translations\Translator::$locale = 'ch'; // or 'ar' +\Ahc\Application::addLocale('en', [ + 'Show help' => 'Shows helpful information about a command', +]); ``` -In the same way, you can override the built-in translations, just create your own file `en.php` or `fr.php`, etc... and indicate the folder in which it is located - -You don't have to translate all the translation keys. If a translation key does not exist in your implementation, the default value will be used. This allows you to translate only the elements you consider important (for example, exception messages do not necessarily need to be translated). +vous pouvez trouver toutes les clés de traduction supportées par le paquet dans cette gist : https://gist.github.com/dimtrovich/1597c16d5c74334e68eef15a4e7ba3fd ## Autocompletion From 9b6f42fa9adbb0ed1685af08e2ec2e9fa26f2d5f Mon Sep 17 00:00:00 2001 From: Dimitri Sitchet Tomkeu Date: Tue, 3 Dec 2024 11:17:36 +0100 Subject: [PATCH 10/11] patch: use simple function `t()` instead method `translate` --- composer.json | 5 ++++- src/Application.php | 10 ++++------ src/Helper/InflectsString.php | 13 ------------- src/Helper/OutputHelper.php | 17 +++++++++-------- src/Helper/Shell.php | 11 +++++------ src/IO/Interactor.php | 8 +++----- src/Input/Command.php | 18 +++++++++--------- src/Input/Parameter.php | 3 ++- src/Input/Parser.php | 11 +++-------- src/Output/Color.php | 12 +++++------- src/Output/ProgressBar.php | 10 ++++------ src/Output/Table.php | 3 ++- src/functions.php | 23 +++++++++++++++++++++++ tests/ApplicationTest.php | 25 +++++++++++++++++++++++++ tests/Helper/InflectsStringTest.php | 25 ------------------------- 15 files changed, 98 insertions(+), 96 deletions(-) create mode 100644 src/functions.php diff --git a/composer.json b/composer.json index b942bb9..e91b0fc 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,10 @@ "autoload": { "psr-4": { "Ahc\\Cli\\": "src/" - } + }, + "files": [ + "src/functions.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/src/Application.php b/src/Application.php index 88b0cb0..27a70a4 100644 --- a/src/Application.php +++ b/src/Application.php @@ -40,8 +40,6 @@ */ class Application { - use InflectsString; - /** * Locale of CLI. */ @@ -189,7 +187,7 @@ public function add(Command $command, string $alias = '', bool $default = false) $this->aliases[$alias] ?? null ) { - throw new InvalidArgumentException($this->translate('Command "%s" already added', [$name])); + throw new InvalidArgumentException(t('Command "%s" already added', [$name])); } if ($alias) { @@ -218,7 +216,7 @@ public function add(Command $command, string $alias = '', bool $default = false) public function defaultCommand(string $commandName): self { if (!isset($this->commands[$commandName])) { - throw new InvalidArgumentException($this->translate('Command "%s" does not exist', [$commandName])); + throw new InvalidArgumentException(t('Command "%s" does not exist', [$commandName])); } $this->default = $commandName; @@ -414,8 +412,8 @@ public function showHelp(): mixed public function showDefaultHelp(): mixed { $writer = $this->io()->writer(); - $header = "{$this->name}, {$this->translate('version')} {$this->version}"; - $footer = $this->translate('Run ` --help` for specific help'); + $header = "{$this->name}, " . t('version') . " {$this->version}"; + $footer = t('Run ` --help` for specific help'); if ($this->logo) { $writer->logo($this->logo, true); diff --git a/src/Helper/InflectsString.php b/src/Helper/InflectsString.php index 1d2ea74..243a8d6 100644 --- a/src/Helper/InflectsString.php +++ b/src/Helper/InflectsString.php @@ -11,8 +11,6 @@ namespace Ahc\Cli\Helper; -use Ahc\Cli\Application; - use function lcfirst; use function mb_strwidth; use function mb_substr; @@ -77,15 +75,4 @@ public function substr(string $string, int $start, ?int $length = null): string return substr($string, $start, $length); } - - /** - * Translates a message using the translator. - */ - public static function translate(string $text, array $args = []): string - { - $translations = Application::$locales[Application::$locale] ?? []; - $text = $translations[$text] ?? $text; - - return sprintf($text, ...$args); - } } diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index 2b65ae0..00e2af7 100644 --- a/src/Helper/OutputHelper.php +++ b/src/Helper/OutputHelper.php @@ -20,6 +20,7 @@ use Ahc\Cli\Output\Writer; use Throwable; +use function Ahc\Cli\t; use function array_map; use function array_shift; use function asort; @@ -79,7 +80,7 @@ public function printTrace(Throwable $e): void $this->writer->colors( "{$eClass} {$e->getMessage()}" . - "({$this->translate('thrown in')} {$e->getFile()}:{$e->getLine()})" + '(' . t('thrown in') . " {$e->getFile()}:{$e->getLine()})" ); // @codeCoverageIgnoreStart @@ -89,7 +90,7 @@ public function printTrace(Throwable $e): void } // @codeCoverageIgnoreEnd - $traceStr = "{$this->translate('Stack Trace')}:"; + $traceStr = '' . t('Stack Trace') . ':'; foreach ($e->getTrace() as $i => $trace) { $trace += ['class' => '', 'type' => '', 'function' => '', 'file' => '', 'line' => '', 'args' => []]; @@ -99,7 +100,7 @@ public function printTrace(Throwable $e): void $traceStr .= " $i) $symbol($args)"; if ('' !== $trace['file']) { $file = realpath($trace['file']); - $traceStr .= " {$this->translate('at')} $file:{$trace['line']}"; + $traceStr .= " " . t('at') . " $file:{$trace['line']}"; } } @@ -187,7 +188,7 @@ protected function showHelp(string $for, array $items, string $header = '', stri $this->writer->help_header($header, true); } - $this->writer->eol()->help_category($this->translate($for) . ':', true); + $this->writer->eol()->help_category(t($for) . ':', true); if (empty($items)) { $this->writer->help_text(' (n/a)', true); @@ -231,7 +232,7 @@ public function showUsage(string $usage): self $usage = str_replace('$0', $_SERVER['argv'][0] ?? '[cmd]', $usage); if (!str_contains($usage, ' ## ')) { - $this->writer->eol()->help_category($this->translate('Usage Examples') . ':', true)->colors($usage)->eol(); + $this->writer->eol()->help_category(t('Usage Examples') . ':', true)->colors($usage)->eol(); return $this; } @@ -248,7 +249,7 @@ public function showUsage(string $usage): self return str_pad('# ', $maxlen - array_shift($lines), ' ', STR_PAD_LEFT); }, $usage); - $this->writer->eol()->help_category($this->translate('Usage Examples') . ':', true)->colors($usage)->eol(); + $this->writer->eol()->help_category(t('Usage Examples') . ':', true)->colors($usage)->eol(); return $this; } @@ -263,11 +264,11 @@ public function showCommandNotFound(string $attempted, array $available): self } } - $this->writer->error($this->translate('Command %s not found', [$attempted]), true); + $this->writer->error(t('Command %s not found', [$attempted]), true); if ($closest) { asort($closest); $closest = key($closest); - $this->writer->bgRed($this->translate('Did you mean %s?', [$closest]), true); + $this->writer->bgRed(t('Did you mean %s?', [$closest]), true); } return $this; diff --git a/src/Helper/Shell.php b/src/Helper/Shell.php index 251c12a..d6dc227 100644 --- a/src/Helper/Shell.php +++ b/src/Helper/Shell.php @@ -13,6 +13,7 @@ use Ahc\Cli\Exception\RuntimeException; +use function Ahc\Cli\t; use function fclose; use function function_exists; use function fwrite; @@ -37,8 +38,6 @@ */ class Shell { - use InflectsString; - const STDIN_DESCRIPTOR_KEY = 0; const STDOUT_DESCRIPTOR_KEY = 1; const STDERR_DESCRIPTOR_KEY = 2; @@ -101,7 +100,7 @@ public function __construct(protected string $command, protected ?string $input { // @codeCoverageIgnoreStart if (!function_exists('proc_open')) { - throw new RuntimeException($this->translate('Required proc_open could not be found in your PHP setup.')); + throw new RuntimeException(t('Required proc_open could not be found in your PHP setup.')); } // @codeCoverageIgnoreEnd @@ -183,7 +182,7 @@ protected function checkTimeout(): void if ($executionDuration > $this->processTimeout) { $this->kill(); - throw new RuntimeException($this->translate('Timeout occurred, process terminated.')); + throw new RuntimeException(t('Timeout occurred, process terminated.')); } // @codeCoverageIgnoreStart } @@ -218,7 +217,7 @@ public function setOptions( public function execute(bool $async = false, ?array $stdin = null, ?array $stdout = null, ?array $stderr = null): self { if ($this->isRunning()) { - throw new RuntimeException($this->translate('Process is already running.')); + throw new RuntimeException(t('Process is already running.')); } $this->descriptors = $this->prepareDescriptors($stdin, $stdout, $stderr); @@ -236,7 +235,7 @@ public function execute(bool $async = false, ?array $stdin = null, ?array $stdou // @codeCoverageIgnoreStart if (!is_resource($this->process)) { - throw new RuntimeException($this->translate('Bad program could not be started.')); + throw new RuntimeException(t('Bad program could not be started.')); } // @codeCoverageIgnoreEnd diff --git a/src/IO/Interactor.php b/src/IO/Interactor.php index 0e8b5f0..b7af721 100644 --- a/src/IO/Interactor.php +++ b/src/IO/Interactor.php @@ -11,11 +11,11 @@ namespace Ahc\Cli\IO; -use Ahc\Cli\Helper\InflectsString; use Ahc\Cli\Input\Reader; use Ahc\Cli\Output\Writer; use Throwable; +use function Ahc\Cli\t; use function array_keys; use function array_map; use function count; @@ -196,8 +196,6 @@ */ class Interactor { - use InflectsString; - protected Reader $reader; protected Writer $writer; @@ -315,7 +313,7 @@ public function choices(string $text, array $choices, $default = null, bool $cas */ public function prompt(string $text, $default = null, ?callable $fn = null, int $retry = 3): mixed { - $error = $this->translate('Invalid value. Please try again!'); + $error = t('Invalid value. Please try again!'); $hidden = func_get_args()[4] ?? false; $readFn = ['read', 'readHidden'][(int) $hidden]; @@ -373,7 +371,7 @@ protected function listOptions(array $choices, $default = null, bool $multi = fa $this->writer->eol()->choice(str_pad(" [$choice]", $maxLen + 6))->answer($desc); } - $label = $this->translate($multi ? 'Choices (comma separated)' : 'Choice'); + $label = t($multi ? 'Choices (comma separated)' : 'Choice'); $this->writer->eol()->question($label); diff --git a/src/Input/Command.php b/src/Input/Command.php index 2257d6d..bcefe31 100644 --- a/src/Input/Command.php +++ b/src/Input/Command.php @@ -21,12 +21,12 @@ use Ahc\Cli\Output\Writer; use Closure; +use function Ahc\Cli\t; use function array_filter; use function array_keys; use function end; use function explode; use function func_num_args; -use function sprintf; use function str_contains; use function strstr; @@ -83,9 +83,9 @@ public function __construct( */ protected function defaults(): self { - $this->option('-h, --help', $this->translate('Show help'))->on([$this, 'showHelp']); - $this->option('-V, --version', $this->translate('Show version'))->on([$this, 'showVersion']); - $this->option('-v, --verbosity', $this->translate('Verbosity level'), null, 0)->on( + $this->option('-h, --help', t('Show help'))->on([$this, 'showHelp']); + $this->option('-V, --version', t('Show version'))->on([$this, 'showVersion']); + $this->option('-v, --verbosity', t('Verbosity level'), null, 0)->on( fn () => $this->set('verbosity', ($this->verbosity ?? 0) + 1) && false ); @@ -196,7 +196,7 @@ public function argument(string $raw, string $desc = '', $default = null): self $argument = new Argument($raw, $desc, $default); if ($this->_argVariadic) { - throw new InvalidParameterException($this->translate('Only last argument can be variadic')); + throw new InvalidParameterException(t('Only last argument can be variadic')); } if ($argument->variadic()) { @@ -303,7 +303,7 @@ protected function handleUnknown(string $arg, ?string $value = null): mixed // Has some value, error! if ($values) { - throw new RuntimeException($this->translate('Option "%s" not registered', [$arg])); + throw new RuntimeException(t('Option "%s" not registered', [$arg])); } // Has no value, show help! @@ -356,13 +356,13 @@ public function showDefaultHelp(): mixed $io->logo($logo, true); } - $io->help_header("{$this->translate('Command')} {$this->_name}, {$this->translate('version')} {$this->_version}", true)->eol(); + $io->help_header(t('Command') . " {$this->_name}, " . t('version') . " {$this->_version}", true)->eol(); $io->help_summary($this->_desc, true)->eol(); - $io->help_text("{$this->translate('Usage')}: ")->help_example("{$this->_name} {$this->translate('[OPTIONS...] [ARGUMENTS...]')}", true); + $io->help_text(t('Usage') . ': ')->help_example("{$this->_name} " . t('[OPTIONS...] [ARGUMENTS...]'), true); $helper ->showArgumentsHelp($this->allArguments()) - ->showOptionsHelp($this->allOptions(), '', $this->translate('Legend: [optional] variadic...')); + ->showOptionsHelp($this->allOptions(), '', t('Legend: [optional] variadic...')); if ($this->_usage) { $helper->showUsage($this->_usage); diff --git a/src/Input/Parameter.php b/src/Input/Parameter.php index e443d31..cf2e6f5 100644 --- a/src/Input/Parameter.php +++ b/src/Input/Parameter.php @@ -13,6 +13,7 @@ use Ahc\Cli\Helper\InflectsString; +use function Ahc\Cli\t; use function json_encode; use function ltrim; use function strpos; @@ -83,7 +84,7 @@ public function desc(bool $withDefault = false): string return $this->desc; } - return ltrim($this->translate('%s [default: %s]', [$this->desc, json_encode($this->default)])); + return ltrim(t('%s [default: %s]', [$this->desc, json_encode($this->default)])); } /** diff --git a/src/Input/Parser.php b/src/Input/Parser.php index f11b973..d2f4fd2 100644 --- a/src/Input/Parser.php +++ b/src/Input/Parser.php @@ -13,10 +13,10 @@ use Ahc\Cli\Exception\InvalidParameterException; use Ahc\Cli\Exception\RuntimeException; -use Ahc\Cli\Helper\InflectsString; use Ahc\Cli\Helper\Normalizer; use InvalidArgumentException; +use function Ahc\Cli\t; use function array_diff_key; use function array_filter; use function array_key_exists; @@ -25,7 +25,6 @@ use function count; use function in_array; use function reset; -use function sprintf; use function substr; /** @@ -38,8 +37,6 @@ */ abstract class Parser { - use InflectsString; - /** @var string|null The last seen variadic option name */ protected ?string $_lastVariadic = null; @@ -224,9 +221,7 @@ protected function validate(): void [$name, $label] = [$item->long(), 'Option']; } - throw new RuntimeException( - $this->translate('%s "%s" is required', [$label, $name]) - ); + throw new RuntimeException(t('%s "%s" is required', [$label, $name])); } } @@ -267,7 +262,7 @@ public function unset(string $name): self protected function ifAlreadyRegistered(Parameter $param): void { if ($this->registered($param->attributeName())) { - throw new InvalidParameterException($this->translate( + throw new InvalidParameterException(t( 'The parameter "%s" is already registered', [$param instanceof Option ? $param->long() : $param->name()] )); diff --git a/src/Output/Color.php b/src/Output/Color.php index 6c1485c..c5becdb 100644 --- a/src/Output/Color.php +++ b/src/Output/Color.php @@ -12,8 +12,8 @@ namespace Ahc\Cli\Output; use Ahc\Cli\Exception\InvalidArgumentException; -use Ahc\Cli\Helper\InflectsString; +use function Ahc\Cli\t; use function array_intersect_key; use function constant; use function defined; @@ -39,8 +39,6 @@ */ class Color { - use InflectsString; - const BLACK = 30; const RED = 31; const GREEN = 32; @@ -200,12 +198,12 @@ public static function style(string $name, array $style): void $style = array_intersect_key($style, $allow); if (empty($style)) { - throw new InvalidArgumentException(self::translate('Trying to set empty or invalid style')); + throw new InvalidArgumentException(t('Trying to set empty or invalid style')); } $invisible = (isset($style['bg']) && isset($style['fg']) && $style['bg'] === $style['fg']); if ($invisible && method_exists(static::class, $name)) { - throw new InvalidArgumentException(self::translate('Built-in styles cannot be invisible')); + throw new InvalidArgumentException(t('Built-in styles cannot be invisible')); } static::$styles[$name] = $style; @@ -222,7 +220,7 @@ public static function style(string $name, array $style): void public function __call(string $name, array $arguments): string { if (!isset($arguments[0])) { - throw new InvalidArgumentException($this->translate('Text required')); + throw new InvalidArgumentException(t('Text required')); } [$name, $text, $style] = $this->parseCall($name, $arguments); @@ -237,7 +235,7 @@ public function __call(string $name, array $arguments): string } if (!method_exists($this, $name)) { - throw new InvalidArgumentException($this->translate('Style "%s" not defined', [$name])); + throw new InvalidArgumentException(t('Style "%s" not defined', [$name])); } return $this->{$name}($text, $style); diff --git a/src/Output/ProgressBar.php b/src/Output/ProgressBar.php index 4286677..0f13840 100644 --- a/src/Output/ProgressBar.php +++ b/src/Output/ProgressBar.php @@ -11,10 +11,10 @@ namespace Ahc\Cli\Output; -use Ahc\Cli\Helper\InflectsString; use Ahc\Cli\Helper\Terminal; use UnexpectedValueException; +use function Ahc\Cli\t; use function count; use function implode; use function in_array; @@ -32,8 +32,6 @@ */ class ProgressBar { - use InflectsString; - /** * The total number of items involved. */ @@ -136,7 +134,7 @@ public function option(string|array $key, ?string $value = null): self { if (is_string($key)) { if (empty($value)) { - throw new UnexpectedValueException($this->translate('Configuration option value is required')); + throw new UnexpectedValueException(t('Configuration option value is required')); } $key = [$key => $value]; @@ -166,12 +164,12 @@ public function current(int $current, string $label = '') { if ($this->total == 0) { // Avoid dividing by 0 - throw new UnexpectedValueException($this->translate('The progress total must be greater than zero.')); + throw new UnexpectedValueException(t('The progress total must be greater than zero.')); } if ($current > $this->total) { throw new UnexpectedValueException( - $this->translate('The current (%d) is greater than the total (%d).', [$current, $this->total]) + t('The current (%d) is greater than the total (%d).', [$current, $this->total]) ); } diff --git a/src/Output/Table.php b/src/Output/Table.php index 1837389..dbcc059 100644 --- a/src/Output/Table.php +++ b/src/Output/Table.php @@ -14,6 +14,7 @@ use Ahc\Cli\Exception\InvalidArgumentException; use Ahc\Cli\Helper\InflectsString; +use function Ahc\Cli\t; use function array_column; use function array_fill_keys; use function array_keys; @@ -106,7 +107,7 @@ protected function normalize(array $rows): array if (!is_array($head)) { throw new InvalidArgumentException( - $this->translate('Rows must be array of assoc arrays, %s given', [gettype($head)]) + t('Rows must be array of assoc arrays, %s given', [gettype($head)]) ); } diff --git a/src/functions.php b/src/functions.php new file mode 100644 index 0000000..5de8ae9 --- /dev/null +++ b/src/functions.php @@ -0,0 +1,23 @@ + + * + * + * Licensed under MIT license. + */ + +namespace Ahc\Cli; + +/** + * Translates a message. + */ +function t(string $text, array $args = []): string +{ + $translations = Application::$locales[Application::$locale] ?? []; + $text = $translations[$text] ?? $text; + + return sprintf($text, ...$args); +} diff --git a/tests/ApplicationTest.php b/tests/ApplicationTest.php index 2b70a6f..13c889c 100644 --- a/tests/ApplicationTest.php +++ b/tests/ApplicationTest.php @@ -18,6 +18,8 @@ use PHPUnit\Framework\TestCase; use Throwable; +use function Ahc\Cli\t; + class ApplicationTest extends TestCase { protected static $in = __DIR__ . '/input.test'; @@ -326,6 +328,29 @@ public function test_on_exception() $app->handle(['test', 'cmd']); } + public function test_default_translations() + { + $this->assertSame('Show version', t('Show version')); + $this->assertSame('Verbosity level [default: 0]', t('%s [default: %s]', ['Verbosity level', 0])); + $this->assertSame('Command "rmdir" already added', t('Command "%s" already added', ['rmdir'])); + } + + public function test_custom_translations(): void + { + Application::addLocale('fr', [ + 'Show version' => 'Afficher la version', + '%s [default: %s]' => '%s [par défaut: %s]', + 'Command "%s" already added' => 'La commande "%s" a déjà été ajoutée' + ], true); + + $this->assertSame('Afficher la version', t('Show version')); + $this->assertSame('Niveau de verbosite [par défaut: 0]', t('%s [default: %s]', ['Niveau de verbosite', 0])); + $this->assertSame('La commande "rmdir" a déjà été ajoutée', t('Command "%s" already added', ['rmdir'])); + + // untranslated key + $this->assertSame('Show help', t('Show help')); + } + public function test_app_translated() { $app = $this->newApp('test'); diff --git a/tests/Helper/InflectsStringTest.php b/tests/Helper/InflectsStringTest.php index 9eb3220..c472548 100644 --- a/tests/Helper/InflectsStringTest.php +++ b/tests/Helper/InflectsStringTest.php @@ -11,7 +11,6 @@ namespace Ahc\Cli\Test\Helper; -use Ahc\Cli\Application; use Ahc\Cli\Helper\InflectsString; use PHPUnit\Framework\TestCase; @@ -32,28 +31,4 @@ public function test_to_words() $this->assertSame('The Long Name', $this->toWords('--the_long-name')); $this->assertSame('A BC', $this->toWords('a_bC')); } - - public function test_default_translate(): void - { - $this->assertSame('Show version', $this->translate('Show version')); - $this->assertSame('Verbosity level [default: 0]', $this->translate('%s [default: %s]', ['Verbosity level', 0])); - $this->assertSame('Command "rmdir" already added', $this->translate('Command "%s" already added', ['rmdir'])); - } - - public function test_custom_translations(): void - { - Application::addLocale('fr', [ - 'Show version' => 'Afficher la version', - '%s [default: %s]' => '%s [par défaut: %s]', - 'Command "%s" already added' => 'La commande "%s" a déjà été ajoutée' - ], true); - - - $this->assertSame('Afficher la version', $this->translate('Show version')); - $this->assertSame('Verbosity level [par défaut: 0]', $this->translate('%s [default: %s]', ['Verbosity level', 0])); - $this->assertSame('La commande "rmdir" a déjà été ajoutée', $this->translate('Command "%s" already added', ['rmdir'])); - - // untranslated key - $this->assertSame('Show help', $this->translate('Show help')); - } } From be819d422651b9cbdd4bfb28ac87e8fd34af2f31 Mon Sep 17 00:00:00 2001 From: Dimitri Sitchet Tomkeu Date: Tue, 3 Dec 2024 19:10:33 +0100 Subject: [PATCH 11/11] patch: manage argrument positioning of sprintf in translation see https://www.php.net/manual/en/function.sprintf.php#refsect1-function.sprintf-examples --- src/Input/Parameter.php | 2 +- src/Input/Parser.php | 2 +- src/Output/ProgressBar.php | 2 +- tests/ApplicationTest.php | 8 ++++---- tests/Output/ProgressBarTest.php | 1 + 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Input/Parameter.php b/src/Input/Parameter.php index cf2e6f5..71088b9 100644 --- a/src/Input/Parameter.php +++ b/src/Input/Parameter.php @@ -84,7 +84,7 @@ public function desc(bool $withDefault = false): string return $this->desc; } - return ltrim(t('%s [default: %s]', [$this->desc, json_encode($this->default)])); + return ltrim(t('%1$s [default: %2$s]', [$this->desc, json_encode($this->default)])); } /** diff --git a/src/Input/Parser.php b/src/Input/Parser.php index d2f4fd2..3717be0 100644 --- a/src/Input/Parser.php +++ b/src/Input/Parser.php @@ -221,7 +221,7 @@ protected function validate(): void [$name, $label] = [$item->long(), 'Option']; } - throw new RuntimeException(t('%s "%s" is required', [$label, $name])); + throw new RuntimeException(t('%1$s "%2$s" is required', [$label, $name])); } } diff --git a/src/Output/ProgressBar.php b/src/Output/ProgressBar.php index 0f13840..b9593c0 100644 --- a/src/Output/ProgressBar.php +++ b/src/Output/ProgressBar.php @@ -169,7 +169,7 @@ public function current(int $current, string $label = '') if ($current > $this->total) { throw new UnexpectedValueException( - t('The current (%d) is greater than the total (%d).', [$current, $this->total]) + t('The current (%1$d) is greater than the total (%2$d).', [$current, $this->total]) ); } diff --git a/tests/ApplicationTest.php b/tests/ApplicationTest.php index 13c889c..083f4c9 100644 --- a/tests/ApplicationTest.php +++ b/tests/ApplicationTest.php @@ -331,7 +331,7 @@ public function test_on_exception() public function test_default_translations() { $this->assertSame('Show version', t('Show version')); - $this->assertSame('Verbosity level [default: 0]', t('%s [default: %s]', ['Verbosity level', 0])); + $this->assertSame('Verbosity level [default: 0]', t('%1$s [default: %2$s]', ['Verbosity level', 0])); $this->assertSame('Command "rmdir" already added', t('Command "%s" already added', ['rmdir'])); } @@ -339,12 +339,12 @@ public function test_custom_translations(): void { Application::addLocale('fr', [ 'Show version' => 'Afficher la version', - '%s [default: %s]' => '%s [par défaut: %s]', + '%1$s [default: %2$s]' => '%1$s [par défaut: %2$s]', 'Command "%s" already added' => 'La commande "%s" a déjà été ajoutée' ], true); $this->assertSame('Afficher la version', t('Show version')); - $this->assertSame('Niveau de verbosite [par défaut: 0]', t('%s [default: %s]', ['Niveau de verbosite', 0])); + $this->assertSame('Niveau de verbosite [par défaut: 0]', t('%1$s [default: %2$s]', ['Niveau de verbosite', 0])); $this->assertSame('La commande "rmdir" a déjà été ajoutée', t('Command "%s" already added', ['rmdir'])); // untranslated key @@ -357,7 +357,7 @@ public function test_app_translated() $app->addLocale('fr', [ 'Show version' => 'Afficher la version', 'Verbosity level' => 'Niveau de verbocité', - '%s [default: %s]' => '%s [par défaut: %s]', + '%1$s [default: %2$s]' => '%s [par défaut: %s]', ], true); $app->command('rmdir'); diff --git a/tests/Output/ProgressBarTest.php b/tests/Output/ProgressBarTest.php index 3a4b5d8..c8c1e07 100644 --- a/tests/Output/ProgressBarTest.php +++ b/tests/Output/ProgressBarTest.php @@ -78,6 +78,7 @@ public function getIterator(): Traversable $this->assertNotNull((new Terminal)->height()); $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('The current (2) is greater than the total (1).'); (new ProgressBar(1))->current(2); } }