diff --git a/README.md b/README.md index 3e1e3ee..d5445f1 100644 --- a/README.md +++ b/README.md @@ -828,7 +828,29 @@ 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 the texts generated by our system are in English. But you can easily modify them by defining your translations as follows + +```php +\Ahc\Application::addLocale('fr', [ + 'Only last argument can be variadic' => 'Seul le dernier argument peut être variadique', +], true); +``` + +You can also change the default English text to make the description more explicit if you wish. + +```php +\Ahc\Application::addLocale('en', [ + 'Show help' => 'Shows helpful information about a command', +]); +``` + +vous pouvez trouver toutes les clés de traduction supportées par le paquet dans cette gist : https://gist.github.com/dimtrovich/1597c16d5c74334e68eef15a4e7ba3fd + +## 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. 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 187089c..27a70a4 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,23 @@ */ class Application { + /** + * 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 +147,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 +187,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(t('Command "%s" already added', [$name])); } if ($alias) { @@ -190,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(sprintf('Command "%s" does not exist', $commandName)); + throw new InvalidArgumentException(t('Command "%s" does not exist', [$commandName])); } $this->default = $commandName; @@ -386,8 +412,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}, " . t('version') . " {$this->version}"; + $footer = t('Run ` --help` for specific help'); if ($this->logo) { $writer->logo($this->logo, true); diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index 5f54200..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; @@ -58,6 +59,8 @@ */ class OutputHelper { + use InflectsString; + protected Writer $writer; /** @var int Max width of command name */ @@ -77,7 +80,7 @@ public function printTrace(Throwable $e): void $this->writer->colors( "{$eClass} {$e->getMessage()}" . - "(thrown in {$e->getFile()}:{$e->getLine()})" + '(' . t('thrown in') . " {$e->getFile()}:{$e->getLine()})" ); // @codeCoverageIgnoreStart @@ -87,7 +90,7 @@ public function printTrace(Throwable $e): void } // @codeCoverageIgnoreEnd - $traceStr = 'Stack Trace:'; + $traceStr = '' . t('Stack Trace') . ':'; foreach ($e->getTrace() as $i => $trace) { $trace += ['class' => '', 'type' => '', 'function' => '', 'file' => '', 'line' => '', 'args' => []]; @@ -97,7 +100,7 @@ public function printTrace(Throwable $e): void $traceStr .= " $i) $symbol($args)"; if ('' !== $trace['file']) { $file = realpath($trace['file']); - $traceStr .= " at $file:{$trace['line']}"; + $traceStr .= " " . t('at') . " $file:{$trace['line']}"; } } @@ -185,7 +188,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(t($for) . ':', true); if (empty($items)) { $this->writer->help_text(' (n/a)', true); @@ -229,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('Usage Examples:', true)->colors($usage)->eol(); + $this->writer->eol()->help_category(t('Usage Examples') . ':', true)->colors($usage)->eol(); return $this; } @@ -246,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('Usage Examples:', true)->colors($usage)->eol(); + $this->writer->eol()->help_category(t('Usage Examples') . ':', true)->colors($usage)->eol(); return $this; } @@ -261,11 +264,11 @@ public function showCommandNotFound(string $attempted, array $available): self } } - $this->writer->error("Command $attempted not found", true); + $this->writer->error(t('Command %s not found', [$attempted]), true); if ($closest) { asort($closest); $closest = key($closest); - $this->writer->bgRed("Did you mean $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 3666d5d..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; @@ -99,7 +100,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(t('Required proc_open could not be found in your PHP setup.')); } // @codeCoverageIgnoreEnd @@ -181,7 +182,7 @@ protected function checkTimeout(): void if ($executionDuration > $this->processTimeout) { $this->kill(); - throw new RuntimeException('Timeout occurred, process terminated.'); + throw new RuntimeException(t('Timeout occurred, process terminated.')); } // @codeCoverageIgnoreStart } @@ -216,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('Process is already running.'); + throw new RuntimeException(t('Process is already running.')); } $this->descriptors = $this->prepareDescriptors($stdin, $stdout, $stderr); @@ -234,7 +235,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(t('Bad program could not be started.')); } // @codeCoverageIgnoreEnd diff --git a/src/IO/Interactor.php b/src/IO/Interactor.php index 9112383..b7af721 100644 --- a/src/IO/Interactor.php +++ b/src/IO/Interactor.php @@ -15,6 +15,7 @@ use Ahc\Cli\Output\Writer; use Throwable; +use function Ahc\Cli\t; use function array_keys; use function array_map; use function count; @@ -312,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 = 'Invalid value. Please try again!'; + $error = t('Invalid value. Please try again!'); $hidden = func_get_args()[4] ?? false; $readFn = ['read', 'readHidden'][(int) $hidden]; @@ -370,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 = $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 0cfcf3b..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', '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', 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('Only last argument can be variadic'); + throw new InvalidParameterException(t('Only last argument can be variadic')); } 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(t('Option "%s" not registered', [$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(t('Command') . " {$this->_name}, " . t('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(t('Usage') . ': ')->help_example("{$this->_name} " . t('[OPTIONS...] [ARGUMENTS...]'), true); $helper ->showArgumentsHelp($this->allArguments()) - ->showOptionsHelp($this->allOptions(), '', '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 46236ff..71088b9 100644 --- a/src/Input/Parameter.php +++ b/src/Input/Parameter.php @@ -13,10 +13,10 @@ use Ahc\Cli\Helper\InflectsString; +use function Ahc\Cli\t; use function json_encode; use function ltrim; use function strpos; -use function sprintf; /** * Cli Parameter. @@ -84,7 +84,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(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 42977ca..3717be0 100644 --- a/src/Input/Parser.php +++ b/src/Input/Parser.php @@ -16,6 +16,7 @@ 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; @@ -24,7 +25,6 @@ use function count; use function in_array; use function reset; -use function sprintf; use function substr; /** @@ -221,9 +221,7 @@ protected function validate(): void [$name, $label] = [$item->long(), 'Option']; } - throw new RuntimeException( - sprintf('%s "%s" is required', $label, $name) - ); + throw new RuntimeException(t('%1$s "%2$s" is required', [$label, $name])); } } @@ -264,9 +262,9 @@ public function unset(string $name): self protected function ifAlreadyRegistered(Parameter $param): void { if ($this->registered($param->attributeName())) { - throw new InvalidParameterException(sprintf( + throw new InvalidParameterException(t( 'The parameter "%s" is already registered', - $param instanceof Option ? $param->long() : $param->name() + [$param instanceof Option ? $param->long() : $param->name()] )); } } diff --git a/src/Output/Color.php b/src/Output/Color.php index 4111038..c5becdb 100644 --- a/src/Output/Color.php +++ b/src/Output/Color.php @@ -13,13 +13,13 @@ use Ahc\Cli\Exception\InvalidArgumentException; +use function Ahc\Cli\t; use function array_intersect_key; use function constant; use function defined; 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; @@ -198,12 +198,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(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('Built-in styles cannot be invisible'); + throw new InvalidArgumentException(t('Built-in styles cannot be invisible')); } static::$styles[$name] = $style; @@ -220,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('Text required'); + throw new InvalidArgumentException(t('Text required')); } [$name, $text, $style] = $this->parseCall($name, $arguments); @@ -235,11 +235,7 @@ public function __call(string $name, array $arguments): string } if (!method_exists($this, $name)) { - if (!self::$enabled || getenv('NO_COLOR')) { - return $text; - } - - throw new InvalidArgumentException(sprintf('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 dcb4e27..b9593c0 100644 --- a/src/Output/ProgressBar.php +++ b/src/Output/ProgressBar.php @@ -14,6 +14,7 @@ use Ahc\Cli\Helper\Terminal; use UnexpectedValueException; +use function Ahc\Cli\t; use function count; use function implode; use function in_array; @@ -133,7 +134,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(t('Configuration option value is required')); } $key = [$key => $value]; @@ -163,11 +164,13 @@ 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(t('The progress total must be greater than zero.')); } if ($current > $this->total) { - throw new UnexpectedValueException(sprintf('The current (%d) is greater than the total (%d).', $current, $this->total)); + throw new UnexpectedValueException( + t('The current (%1$d) is greater than the total (%2$d).', [$current, $this->total]) + ); } $this->drawProgressBar($current, $label); diff --git a/src/Output/Table.php b/src/Output/Table.php index 41e6303..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; @@ -24,7 +25,6 @@ use function is_array; use function max; use function reset; -use function sprintf; use function str_repeat; use function trim; @@ -107,7 +107,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)) + 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 4283d0f..083f4c9 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,46 @@ 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('%1$s [default: %2$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', + '%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('%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 + $this->assertSame('Show help', t('Show help')); + } + + public function test_app_translated() + { + $app = $this->newApp('test'); + $app->addLocale('fr', [ + 'Show version' => 'Afficher la version', + 'Verbosity level' => 'Niveau de verbocité', + '%1$s [default: %2$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/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); } }