Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support for localized strings #120

Merged
merged 12 commits into from
Dec 4, 2024
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 16 additions & 0 deletions src/Helper/InflectsString.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

namespace Ahc\Cli\Helper;

use Ahc\Cli\Translations\Translator;

use function lcfirst;
use function mb_strwidth;
use function mb_substr;
Expand All @@ -30,6 +32,8 @@
*/
trait InflectsString
{
private static ?Translator $translator = null;

/**
* Convert a string to camel case.
*/
Expand Down Expand Up @@ -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);
}
}
18 changes: 10 additions & 8 deletions src/Helper/OutputHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
*/
class OutputHelper
{
use InflectsString;

protected Writer $writer;

/** @var int Max width of command name */
Expand All @@ -77,7 +79,7 @@ public function printTrace(Throwable $e): void

$this->writer->colors(
"{$eClass} <red>{$e->getMessage()}</end><eol/>" .
"(thrown in <yellow>{$e->getFile()}</end><white>:{$e->getLine()})</end>"
"({$this->translate('thrownIn')} <yellow>{$e->getFile()}</end><white>:{$e->getLine()})</end>"
);

// @codeCoverageIgnoreStart
Expand All @@ -87,7 +89,7 @@ public function printTrace(Throwable $e): void
}
// @codeCoverageIgnoreEnd

$traceStr = '<eol/><eol/><bold>Stack Trace:</end><eol/><eol/>';
$traceStr = "<eol/><eol/><bold>{$this->translate('stackTrace')}:</end><eol/><eol/>";

foreach ($e->getTrace() as $i => $trace) {
$trace += ['class' => '', 'type' => '', 'function' => '', 'file' => '', 'line' => '', 'args' => []];
Expand All @@ -97,7 +99,7 @@ public function printTrace(Throwable $e): void
$traceStr .= " <comment>$i)</end> <red>$symbol</end><comment>($args)</end>";
if ('' !== $trace['file']) {
$file = realpath($trace['file']);
$traceStr .= "<eol/> <yellow>at $file</end><white>:{$trace['line']}</end><eol/>";
$traceStr .= "<eol/> <yellow>{$this->translate('thrownAt')} $file</end><white>:{$trace['line']}</end><eol/>";
}
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down
10 changes: 6 additions & 4 deletions src/Helper/Shell.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
*/
class Shell
{
use InflectsString;

const STDIN_DESCRIPTOR_KEY = 0;
const STDOUT_DESCRIPTOR_KEY = 1;
const STDERR_DESCRIPTOR_KEY = 2;
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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);
Expand All @@ -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

Expand Down
7 changes: 5 additions & 2 deletions src/IO/Interactor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -195,6 +196,8 @@
*/
class Interactor
{
use InflectsString;

protected Reader $reader;
protected Writer $writer;

Expand Down Expand Up @@ -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];

Expand Down Expand Up @@ -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);

Expand Down
18 changes: 8 additions & 10 deletions src/Input/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
);

Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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!
Expand Down Expand Up @@ -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: <required> [optional] variadic...');
->showOptionsHelp($this->allOptions(), '', $this->translate('optionHelp'));

if ($this->_usage) {
$helper->showUsage($this->_usage);
Expand Down
3 changes: 1 addition & 2 deletions src/Input/Parameter.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
use function json_encode;
use function ltrim;
use function strpos;
use function sprintf;

/**
* Cli Parameter.
Expand Down Expand Up @@ -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)]));
}

/**
Expand Down
9 changes: 6 additions & 3 deletions src/Input/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -37,6 +38,8 @@
*/
abstract class Parser
{
use InflectsString;

/** @var string|null The last seen variadic option name */
protected ?string $_lastVariadic = null;

Expand Down Expand Up @@ -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()]
));
}
}
Expand Down
12 changes: 7 additions & 5 deletions src/Output/Color.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@
namespace Ahc\Cli\Output;

use Ahc\Cli\Exception\InvalidArgumentException;
use Ahc\Cli\Helper\InflectsString;

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;
Expand All @@ -39,6 +39,8 @@
*/
class Color
{
use InflectsString;

const BLACK = 30;
const RED = 31;
const GREEN = 32;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading