From 884e8b55984fd06f6614df23686a2f30aad1d6a0 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 19:53:08 +0700 Subject: [PATCH 01/14] refactor(cursor): add typehints and docblocks --- src/Output/Cursor.php | 83 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/src/Output/Cursor.php b/src/Output/Cursor.php index 4994fbf..4e5e021 100644 --- a/src/Output/Cursor.php +++ b/src/Output/Cursor.php @@ -3,7 +3,7 @@ namespace Ahc\Cli\Output; /** - * Cli Curser. + * Cli Cursor. * * @author Jitendra Adhikari * @license MIT @@ -12,57 +12,124 @@ */ class Cursor { + /** + * Returns signal to move cursor up `n` times. + * + * @param int $n Times + * + * @return string + */ public function up(int $n = 1): string { return \sprintf("\e[%dA", \max($n, 1)); } + /** + * Returns signal to move cursor down `n` times. + * + * @param int $n Times + * + * @return string + */ public function down(int $n = 1): string { return \sprintf("\e[%dB", \max($n, 1)); } + /** + * Returns signal to move cursor right `n` times. + * + * @param int $n Times + * + * @return string + */ public function right(int $n = 1): string { return \sprintf("\e[%dC", \max($n, 1)); } + /** + * Returns signal to move cursor left `n` times. + * + * @param int $n Times + * + * @return string + */ public function left(int $n = 1): string { return \sprintf("\e[%dD", \max($n, 1)); } - public function next(int $n = 1) + /** + * Returns signal to move cursor next line `n` times. + * + * @param int $n Times + * + * @return string + */ + public function next(int $n = 1): string { return \str_repeat("\e[E", \max($n, 1)); } - public function prev(int $n = 1) + /** + * Returns signal to move cursor prev line `n` times. + * + * @param int $n Times + * + * @return string + */ + public function prev(int $n = 1): string { return \str_repeat("\e[F", \max($n, 1)); } - public function eraseLine() + /** + * Returns signal to erase current line. + * + * @return string + */ + public function eraseLine(): string { return "\e[2K"; } - public function clear() + /** + * Returns signal to clear string. + * + * @return string + */ + public function clear(): string { return "\e[2J"; } - public function clearUp() + /** + * Returns signal to erase lines upward. + * + * @return string + */ + public function clearUp(): string { return "\e[1J"; } - public function clearDown() + /** + * Returns signal to erase lines downward. + * + * @return string + */ + public function clearDown(): string { return "\e[J"; } - public function moveTo(int $x, int $y) + /** + * Returns signal to move cursor to given x, y position. + * + * @return string + */ + public function moveTo(int $x, int $y): string { return \sprintf("\e[%d;%dH", $y, $x); } From ddcaeb5ae7cf83cd332747217fb03a8764ae7d46 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 19:57:03 +0700 Subject: [PATCH 02/14] refactor(writer): add typehints and docblocks, colorizer() --- src/Output/Writer.php | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/Output/Writer.php b/src/Output/Writer.php index a8a0e9b..8059e01 100644 --- a/src/Output/Writer.php +++ b/src/Output/Writer.php @@ -40,6 +40,16 @@ public function __construct(string $path = null, Color $colorizer = null) $this->colorizer = $colorizer ?? new Color; } + /** + * Get Colorizer. + * + * @return Color + */ + public function colorizer(): Color + { + return $this->colorizer; + } + /** * Magically set methods. * @@ -78,6 +88,14 @@ public function write(string $text, bool $eol = false): self return $this->doWrite($text, $error); } + /** + * Really write to the stream. + * + * @param string $text + * @param bool $error + * + * @return self + */ protected function doWrite(string $text, bool $error = false): self { $stream = $error ? $this->eStream : $this->stream; @@ -87,11 +105,26 @@ protected function doWrite(string $text, bool $error = false): self return $this; } + /** + * Write EOL n times. + * + * @param int $n + * + * @return self + */ public function eol(int $n = 1): self { return $this->doWrite(\str_repeat(PHP_EOL, \max($n, 1))); } + /** + * Write raw text (as it is). + * + * @param string $text + * @param bool $error + * + * @return self + */ public function raw($text, bool $error = false): self { return $this->doWrite((string) $text, $error); From 420105df91f639e98a42b7ad6cd818fb725cba80 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 19:57:55 +0700 Subject: [PATCH 03/14] refactor(color): add typehints and docblocks --- src/Output/Color.php | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/Output/Color.php b/src/Output/Color.php index 52b96cd..04a93d1 100644 --- a/src/Output/Color.php +++ b/src/Output/Color.php @@ -27,26 +27,66 @@ class Color /** @vstatic ar array Custom styles */ protected static $styles = []; + /** + * Returns a line formatted as comment. + * + * @param string $text + * @param array $style + * + * @return string + */ public function comment(string $text, array $style = []): string { return $this->line($text, ['fg' => static::BLACK, 'bold' => 1] + $style); } + /** + * Returns a line formatted as comment. + * + * @param string $text + * @param array $style + * + * @return string + */ public function error(string $text, array $style = []): string { return $this->line($text, ['fg' => static::RED] + $style); } + /** + * Returns a line formatted as ok msg. + * + * @param string $text + * @param array $style + * + * @return string + */ public function ok(string $text, array $style = []): string { return $this->line($text, ['fg' => static::GREEN] + $style); } + /** + * Returns a line formatted as warning. + * + * @param string $text + * @param array $style + * + * @return string + */ public function warn(string $text, array $style = []): string { return $this->line($text, ['fg' => static::YELLOW] + $style); } + /** + * Returns a line formatted as info. + * + * @param string $text + * @param array $style + * + * @return string + */ public function info(string $text, array $style = []): string { return $this->line($text, ['fg' => static::BLUE] + $style); @@ -62,7 +102,7 @@ public function info(string $text, array $style = []): string */ public function line(string $text, array $style = []): string { - $style += ['bg' => null, 'fg' => static::WHITE, 'bold' => false]; + $style += ['bg' => null, 'fg' => static::WHITE, 'bold' => 0]; $format = $style['bg'] === null ? \str_replace(';:bg:', '', $this->format) @@ -82,7 +122,7 @@ public function line(string $text, array $style = []): string * Register a custom style. * * @param string $name Example: 'alert' - * @param array $style Example: ['fg' => Color::RED, 'bg' => Color::YELLOW, 'bold' => true] + * @param array $style Example: ['fg' => Color::RED, 'bg' => Color::YELLOW, 'bold' => 1] * * @return void */ From c37b88711e1919dd8b97919fe1ad2bcc537ac37c Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 19:59:28 +0700 Subject: [PATCH 04/14] refactor(interactor): highlight default value with color --- src/IO/Interactor.php | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/IO/Interactor.php b/src/IO/Interactor.php index 06b6d28..14bba67 100644 --- a/src/IO/Interactor.php +++ b/src/IO/Interactor.php @@ -30,11 +30,21 @@ public function __construct(string $input = null, string $output = null) $this->writer = new Writer($output); } + /** + * Get reader. + * + * @return Reader + */ public function reader(): Reader { return $this->reader; } + /** + * Get writer. + * + * @return Writer + */ public function writer(): Writer { return $this->writer; @@ -179,13 +189,18 @@ protected function listOptions(array $choices, $default = null, bool $multi = fa */ protected function promptOptions(array $choices, $default): self { - $options = \implode('/', $choices); - - foreach ((array) $default as $value) { - $options = \str_replace($value, \strtoupper($value), $options); + $options = ''; + $color = $this->writer->colorizer(); + + foreach ($choices as $choice) { + if (\in_array($choice, (array) $default)) { + $options .= '/' . $color->boldCyan($choice); + } else { + $options .= '/' . $color->cyan($choice); + } } - $this->writer->cyan(' (' . $options . '): '); + $this->writer->raw(' (' . ltrim($options, '/') . '): '); return $this; } From 152e965d7e2dbce0f8d54b9bbd4c6563d94ffd10 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 20:02:32 +0700 Subject: [PATCH 05/14] refactor(Parameters): add docblocks, consolidate construct() and filter() --- src/Input/Argument.php | 3 ++ src/Input/Option.php | 51 +++++++++++++++----------------- src/Input/Parameter.php | 64 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 29 deletions(-) diff --git a/src/Input/Argument.php b/src/Input/Argument.php index 6d1e9fb..d2a6d2b 100644 --- a/src/Input/Argument.php +++ b/src/Input/Argument.php @@ -28,6 +28,9 @@ protected function parse(string $arg) } } + /** + * {@inheritdoc} + */ public function default() { if (!$this->variadic) { diff --git a/src/Input/Option.php b/src/Input/Option.php index dee10fc..f15000d 100644 --- a/src/Input/Option.php +++ b/src/Input/Option.php @@ -12,18 +12,11 @@ */ class Option extends Parameter { - protected $short; + /** @var string Short name */ + protected $short = ''; - protected $long; - - protected $filter; - - public function __construct(string $raw, string $desc = '', $default = null, callable $filter = null) - { - $this->filter = $filter; - - parent::__construct($raw, $desc, $default); - } + /** @var string Long name */ + protected $long = ''; /** * {@inheritdoc} @@ -46,41 +39,43 @@ protected function parse(string $raw) $this->name = \str_replace(['--', 'no-', 'with-'], '', $this->long); } + /** + * Get long name. + * + * @return string + */ public function long(): string { return $this->long; } + /** + * Get short name. + * + * @return string + */ public function short(): string { return $this->short; } + /** + * Test if this option matches given arg. + * + * @return bool + */ public function is(string $arg): bool { return $this->short === $arg || $this->long === $arg; } - public function bool(): bool - { - return \preg_match('/\-no|\-with/', $this->long) > 0; - } - /** - * Run the filter/sanitizer/validato callback for this prop. + * Check if the option is boolean type. * - * @param mixed $raw - * - * @return mixed + * @return bool */ - public function filter($raw) + public function bool(): bool { - if ($this->filter) { - $callback = $this->filter; - - return $callback($raw); - } - - return $raw; + return \preg_match('/\-no|\-with/', $this->long) > 0; } } diff --git a/src/Input/Parameter.php b/src/Input/Parameter.php index d007eb0..d6aed1f 100644 --- a/src/Input/Parameter.php +++ b/src/Input/Parameter.php @@ -28,6 +28,9 @@ abstract class Parameter /** @var mixed */ protected $default; + /** @var callable The sanitizer/filter callback */ + protected $filter; + /** @var bool */ protected $required = false; @@ -37,11 +40,12 @@ abstract class Parameter /** @var bool */ protected $variadic = false; - public function __construct(string $raw, string $desc = '', $default = null) + public function __construct(string $raw, string $desc = '', $default = null, callable $filter = null) { $this->raw = $raw; $this->desc = $desc; $this->default = $default; + $this->filter = $filter; $this->required = \strpos($raw, '<') !== false; $this->optional = \strpos($raw, '[') !== false; $this->variadic = \strpos($raw, '...') !== false; @@ -58,43 +62,101 @@ public function __construct(string $raw, string $desc = '', $default = null) */ abstract protected function parse(string $raw); + /** + * Get raw definition. + * + * @return string + */ public function raw(): string { return $this->raw; } + /** + * Get name. + * + * @return string + */ public function name(): string { return $this->name; } + /** + * Get description. + * + * @return string + */ public function desc(): string { return $this->desc; } + /** + * Get normalized name. + * + * @return string + */ public function attributeName(): string { return $this->toCamelCase($this->name); } + /** + * Check this param is required. + * + * @return bool + */ public function required(): bool { return $this->required; } + /** + * Check this param is optional. + * + * @return bool + */ public function optional(): bool { return $this->optional; } + /** + * Check this param is variadic. + * + * @return bool + */ public function variadic(): bool { return $this->variadic; } + /** + * Gets default value. + * + * @return mixed + */ public function default() { return $this->default; } + + /** + * Run the filter/sanitizer/validato callback for this prop. + * + * @param mixed $raw + * + * @return mixed + */ + public function filter($raw) + { + if ($this->filter) { + $callback = $this->filter; + + return $callback($raw); + } + + return $raw; + } } From c068ae77b33862f633e09baaca110544a2863801 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 20:04:22 +0700 Subject: [PATCH 06/14] refactor(helpers): add docblocks, fix padlength for single item --- src/Helper/InflectsString.php | 7 +++++++ src/Helper/OutputHelper.php | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Helper/InflectsString.php b/src/Helper/InflectsString.php index fb51844..56dc5ca 100644 --- a/src/Helper/InflectsString.php +++ b/src/Helper/InflectsString.php @@ -12,6 +12,13 @@ */ trait InflectsString { + /** + * Convert a string to camel case. + * + * @param string $string + * + * @return string + */ public function toCamelCase(string $string): string { $words = \str_replace(['-', '_'], ' ', $string); diff --git a/src/Helper/OutputHelper.php b/src/Helper/OutputHelper.php index e699130..c3d9263 100644 --- a/src/Helper/OutputHelper.php +++ b/src/Helper/OutputHelper.php @@ -93,7 +93,8 @@ protected function showHelp(string $for, array $items, int $space, string $heade */ protected function sortItems(array $items, &$max = 0): array { - $max = 0; + $first = reset($items); + $max = \strlen($first->name()); \uasort($items, function ($a, $b) use (&$max) { $max = \max(\strlen($a->name()), \strlen($b->name()), $max); @@ -106,6 +107,10 @@ protected function sortItems(array $items, &$max = 0): array /** * Prepare name for different items. + * + * @param Parameter|Command $item + * + * @return string */ protected function getName($item): string { From a1b09308d0fffc06bf982807cc5578c0cd847910 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 20:10:19 +0700 Subject: [PATCH 07/14] refactor(parser): make attrs private, bring core stuffs from Command, add value set(), register(), allOptions(), allArguments() etc add docblocks and typehints ... --- src/Input/Parser.php | 206 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 175 insertions(+), 31 deletions(-) diff --git a/src/Input/Parser.php b/src/Input/Parser.php index d46e003..8f58c56 100644 --- a/src/Input/Parser.php +++ b/src/Input/Parser.php @@ -16,16 +16,16 @@ abstract class Parser protected $_lastOption; /** @var bool If the last seen option was variadic */ - protected $_wasVariadic = false; + private $_wasVariadic = false; /** @var Option[] Registered options */ - protected $_options = []; + private $_options = []; - /** @var Option[] Registered arguments */ - protected $_arguments = []; + /** @var Argument[] Registered arguments */ + private $_arguments = []; /** @var array Parsed values indexed by option name */ - protected $_values = []; + private $_values = []; /** * Parse the argv input. @@ -84,6 +84,13 @@ protected function normalize(array $args): array return $normalized; } + /** + * Split joined short params like `-ab`. + * + * @param string $arg + * + * @return array + */ protected function splitShort(string $arg): array { $args = \str_split(\substr($arg, 1)); @@ -93,6 +100,13 @@ protected function splitShort(string $arg): array }, $args); } + /** + * Parse single arg. + * + * @param string $arg + * + * @return void + */ protected function parseArgs(string $arg) { if ($arg === '--') { @@ -100,31 +114,33 @@ protected function parseArgs(string $arg) } if ($this->_wasVariadic) { - $this->_values[$this->_lastOption->attributeName()][] = $arg; - - return; + return $this->set($this->_lastOption->attributeName(), $arg, true); } if (!$argument = \reset($this->_arguments)) { - $this->_values[] = $arg; - - return; + return $this->set(null, $arg); } - $name = $argument->attributeName(); - if ($argument->variadic()) { - $this->_values[$name][] = $arg; - - return; - } + $name = $argument->attributeName(); + $variad = $argument->variadic(); - $this->_values[$name] = $arg; + $this->set($name, $argument->filter($arg), $variad); // Otherwise we will always collect same arguments again! - \array_shift($this->_arguments); + if (!$variad) { + \array_shift($this->_arguments); + } } - protected function parseOptions(string $arg, string $nextArg = null) + /** + * Parse an option, emit its event and set value. + * + * @param string $arg + * @param string|null $nextArg + * + * @return bool Whether to eat next arg. + */ + protected function parseOptions(string $arg, string $nextArg = null): bool { $value = \substr($nextArg, 0, 1) === '-' ? null : $nextArg; @@ -187,17 +203,18 @@ abstract protected function emit(string $event, $value = null); /** * Sets value of an option. * - * @param Option $option + * @param Parameter $parameter * @param string|null $value * * @return bool Indicating whether it has eaten adjoining arg to its right. */ - protected function setValue(Option $option, string $value = null): bool + protected function setValue(Parameter $parameter, string $value = null): bool { - $name = $option->attributeName(); - $value = $this->prepareValue($option, $value); + $name = $parameter->attributeName(); - $this->_values[$name] = $value ?? $this->_values[$name]; + if (null !== $value = $this->prepareValue($parameter, $value)) { + $this->set($name, $value); + } return !\in_array($value, [true, false, null], true); } @@ -205,26 +222,44 @@ protected function setValue(Option $option, string $value = null): bool /** * Prepares value as per context and runs thorugh filter if possible. * - * @param Option $option + * @param Parameter $parameter * @param string|null $value * * @return mixed */ - protected function prepareValue(Option $option, string $value = null) + protected function prepareValue(Parameter $parameter, string $value = null) { - if (\is_bool($default = $option->default())) { + if (\is_bool($default = $parameter->default())) { return !$default; } - if ($option->variadic()) { + if ($parameter->variadic()) { return (array) $value; } - if (null === $value && !$option->required()) { + if (null === $value && !$parameter->required()) { return true; } - return null === $value ? null : $option->filter($value); + return null === $value ? null : $parameter->filter($value); + } + + /** + * Set a value. + * + * @param mixed $key + * @param mixed $value + * @param bool $variadic + */ + protected function set($key, $value, bool $variadic = false) + { + if (null === $key) { + $this->_values[] = $value; + } elseif ($variadic) { + $this->_values[$key][] = $value; + } else { + $this->_values[$key] = $value; + } } /** @@ -251,4 +286,113 @@ protected function validate() } } } + + /** + * Register a new argument/option. + * + * @param Parameter $param + * + * @return void + */ + protected function register(Parameter $param) + { + $this->ifAlreadyRegistered($param); + + $name = $param->attributeName(); + if ($param instanceof Option) { + $this->_options[$name] = $param; + } else { + $this->_arguments[$name] = $param; + } + + $this->set($name, $param->default()); + } + + /** + * What if the given name is already registered. + * + * @throws \InvalidArgumentException If given param name is already registered. + */ + protected function ifAlreadyRegistered(Parameter $param) + { + if ($this->registered($param->attributeName())) { + throw new \InvalidArgumentException(\sprintf( + 'The parameter "%s" is already registered', + $param instanceof Option ? $param->long() : $param->name() + )); + } + } + + /** + * Check if either argument/option with given name is registered. + * + * @param string $attribute + * + * @return bool + */ + public function registered($attribute): bool + { + return \array_key_exists($attribute, $this->_values); + } + + /** + * Get all options. + * + * @return Option[] + */ + public function allOptions(): array + { + return $this->_options; + } + + /** + * Get all arguments. + * + * @return Argument[] + */ + public function allArguments(): array + { + return $this->_arguments; + } + + /** + * Magic getter for specific value by its key. + * + * @param string $key + * + * @return mixed + */ + public function __get(string $key) + { + return $this->_values[$key] ?? null; + } + + /** + * Get the command arguments i.e which is not an option. + * + * @return array + */ + public function args(): array + { + return \array_diff_key($this->_values, $this->_options); + } + + /** + * Get values indexed by camelized attribute name. + * + * @param bool $withDefaults + * + * @return array + */ + public function values(bool $withDefaults = true): array + { + $values = $this->_values; + $values['version'] = $this->_version; + + if (!$withDefaults) { + unset($values['help'], $values['version']); + } + + return $values; + } } From f27e39e6d4981a564d251e7dcd297a29ac5fb9df Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 20:16:07 +0700 Subject: [PATCH 08/14] refactor(command): make attrs private, move core stuffs to Parser, add argument(), userOptions(), usage(), interact() and writer() add docblocks and typehints --- src/Input/Command.php | 218 ++++++++++++++++++++++++++---------------- 1 file changed, 135 insertions(+), 83 deletions(-) diff --git a/src/Input/Command.php b/src/Input/Command.php index 449479a..33a4551 100644 --- a/src/Input/Command.php +++ b/src/Input/Command.php @@ -5,6 +5,7 @@ use Ahc\Cli\Application; use Ahc\Cli\Helper\InflectsString; use Ahc\Cli\Helper\OutputHelper; +use Ahc\Cli\IO\Interactor; use Ahc\Cli\Output\Writer; /** @@ -31,14 +32,20 @@ class Command extends Parser /** @var string */ protected $_desc; + /** @var string */ + protected $_usage; + + /** @var Application The cli app this command is bound to */ + protected $_app; + /** @var callable[] Events for options */ - protected $_events = []; + private $_events = []; /** @var bool Whether to allow unknown (not registered) options */ - protected $_allowUnknown = false; + private $_allowUnknown = false; - /** @var Application The cli app this command is bound to */ - protected $_app; + /** @var bool If the last seen arg was variadic */ + private $_argVariadic = false; /** * Constructor. @@ -57,12 +64,17 @@ public function __construct(string $name, string $desc = '', bool $allowUnknown $this->defaults(); } + /** + * Sets default options, actions and exit handler. + * + * @return void + */ 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(function () { - $this->_values['verbosity']++; + $this->set('verbosity', $this->verbosity + 1); return false; }); @@ -90,11 +102,21 @@ public function version(string $version): self return $this; } + /** + * Gets command name. + * + * @return string + */ public function name(): string { return $this->_name; } + /** + * Gets command description. + * + * @return string + */ public function desc(): string { return $this->_desc; @@ -110,6 +132,20 @@ public function app() return $this->_app; } + /** + * Bind command to the app. + * + * @param Application|null $app + * + * @return self + */ + public function bind(Application $app = null): self + { + $this->_app = $app; + + return $this; + } + /** * Registers argument definitions (all at once). Only last one can be variadic. * @@ -120,60 +156,89 @@ public function app() public function arguments(string $definitions): self { $definitions = \explode(' ', $definitions); - foreach ($definitions as $i => $definition) { - $argument = new Argument($definition); - if ($argument->variadic() && isset($definitions[$i + 1])) { - throw new \InvalidArgumentException('Only last argument can be variadic'); - } + foreach ($definitions as $raw) { + $this->argument($raw); + } - $name = $argument->attributeName(); + return $this; + } - $this->ifAlreadyRegistered($name, $argument); + /** + * Register an argument. + * + * @param string $raw + * @param string $desc + * @param mixed $default + * + * @return self + */ + public function argument(string $raw, string $desc = '', $default = null): self + { + $argument = new Argument($raw, $desc, $default); + + if ($this->_argVariadic) { + throw new \InvalidArgumentException('Only last argument can be variadic'); + } - $this->_arguments[$name] = $argument; - $this->_values[$name] = $argument->default(); + if ($argument->variadic()) { + $this->_argVariadic = true; } + $this->register($argument); + return $this; } /** * Registers new option. * - * @param string $cmd + * @param string $raw * @param string $desc * @param callable|null $filter * @param mixed $default * * @return self */ - public function option(string $cmd, string $desc = '', callable $filter = null, $default = null): self + public function option(string $raw, string $desc = '', callable $filter = null, $default = null): self { - $option = new Option($cmd, $desc, $default, $filter); - $name = $option->attributeName(); - - $this->ifAlreadyRegistered($name, $option); + $option = new Option($raw, $desc, $default, $filter); - $this->_options[$name] = $option; - $this->_values[$name] = $option->default(); + $this->register($option); return $this; } /** - * What if the given name is already registered. + * Gets user options (i.e without defaults). + * + * @return string + */ + public function userOptions(): array + { + $options = $this->allOptions(); + + unset($options['help'], $options['version'], $options['verbosity']); + + return $options; + } + + /** + * Gets or sets usage info. + * + * @param string|null $usage * - * @throws \InvalidArgumentException If given param name is already registered. + * @return string|self */ - protected function ifAlreadyRegistered(string $name, Parameter $param) + public function usage(string $usage = null) { - if (\array_key_exists($name, $this->_values)) { - throw new \InvalidArgumentException(\sprintf( - 'The parameter "%s" is already registered', - $param instanceof Option ? $param->long() : $param->name() - )); + if (\func_num_args() === 0) { + return $this->_usage; } + + $this->_usage = $usage; + + return $this; } /** @@ -186,9 +251,9 @@ protected function ifAlreadyRegistered(string $name, Parameter $param) */ public function on(callable $fn, string $option = null): self { - \end($this->_options); + $names = \array_keys($this->allOptions()); - $this->_events[$option ?? \key($this->_options)] = $fn; + $this->_events[$option ?? \end($names)] = $fn; return $this; } @@ -213,12 +278,12 @@ public function onExit(callable $fn): self protected function handleUnknown(string $arg, string $value = null) { if ($this->_allowUnknown) { - $this->_values[$this->toCamelCase($arg)] = $value; + $this->set($this->toCamelCase($arg), $value); return; } - $values = \array_filter($this->_values); + $values = \array_filter($this->values(false)); // Has some value, error! if ($values) { @@ -232,65 +297,35 @@ protected function handleUnknown(string $arg, string $value = null) } /** - * Get values indexed by camelized attribute name. - * - * @param bool $withDefaults - * - * @return array - */ - public function values(bool $withDefaults = true): array - { - $values = $this->_values; - $values['version'] = $this->_version; - - if (!$withDefaults) { - unset($values['help'], $values['version']); - } - - return $values; - } - - /** - * Magic getter for specific value by its key. - * - * @param string $key + * Shows command help then aborts. * * @return mixed */ - public function __get(string $key) - { - return $this->_values[$key] ?? null; - } - - /** - * Get the command arguments i.e which is not an option. - * - * @return array - */ - public function args(): array - { - return \array_diff_key($this->_values, $this->_options); - } - - public function showHelp(Writer $writer = null) + public function showHelp() { - $writer = $writer ?? new Writer; + $writer = $this->writer(); + $helper = new OutputHelper($writer); $writer ->bold("Command {$this->_name}, version {$this->_version}", true)->eol() ->comment($this->_desc, true)->eol() ->bold('Usage: ')->yellow("{$this->_name} [OPTIONS...] [ARGUMENTS...]", true); - (new OutputHelper($writer)) - ->showArgumentsHelp($this->_arguments) - ->showOptionsHelp($this->_options, '', 'Legend: [optional]'); + $helper + ->showArgumentsHelp($this->allArguments()) + ->showOptionsHelp($this->allOptions(), '', 'Legend: [optional]'); return $this->emit('_exit'); } - public function showVersion(Writer $writer = null) + /** + * Shows command version then aborts. + * + * @return mixed + */ + public function showVersion() { - ($writer ?? new Writer)->bold($this->_version, true); + $this->writer()->bold($this->_version, true); return $this->emit('_exit'); } @@ -304,11 +339,6 @@ public function emit(string $event, $value = null) return; } - // Factory events - if (\in_array($event, ['help', 'version'])) { - return ($this->_events[$event])(); - } - return ($this->_events[$event])($value); } @@ -324,6 +354,18 @@ public function tap($object = null) return $object ?? $this->_app; } + /** + * Performs user interaction if required to set some missing values. + * + * @param Interactor $io + * + * @return void + */ + public function interact(Interactor $io) + { + // Subclasses will do the needful. + } + /** * Get or set command action. * @@ -341,4 +383,14 @@ public function action(callable $action = null) return $this; } + + /** + * Get a writer instance. + * + * @return Writer + */ + protected function writer(): Writer + { + return $this->_app ? $this->_app->io()->writer() : new Writer; + } } From 1a5a42f444396d5a3e72adad7ffede1d477fbad9 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 20:59:07 +0700 Subject: [PATCH 09/14] refactor(app): add docblocks, add handle() to parse and invoke action parse() will no longer invoke action, support ascii art logo, add() will accept readymade Command, add io() setter/getter ... --- src/Application.php | 217 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 200 insertions(+), 17 deletions(-) diff --git a/src/Application.php b/src/Application.php index 485f0e1..e81a319 100644 --- a/src/Application.php +++ b/src/Application.php @@ -3,6 +3,7 @@ namespace Ahc\Cli; use Ahc\Cli\Helper\OutputHelper; +use Ahc\Cli\IO\Interactor; use Ahc\Cli\Input\Command; use Ahc\Cli\Output\Writer; @@ -31,45 +32,129 @@ class Application /** @var string App version */ protected $version = '0.0.1'; + /** @var string Ascii art logo */ + protected $logo = ''; + + protected $default = '__default__'; + + /** @var Interactor */ + protected $io; + public function __construct(string $name, string $version = '', callable $onExit = null) { $this->name = $name; $this->version = $version; // @codeCoverageIgnoreStart - $this->onExit = $onExit ?? function () { - exit(0); + $this->onExit = $onExit ?? function ($exitCode = 0) { + exit($exitCode); }; // @codeCoverageIgnoreEnd - $this->command = $this->command('__default__', 'Default command', '', true) - ->on([$this, 'showHelp'], 'help'); - - unset($this->commands['__default__']); + $this->command('__default__', 'Default command', '', true)->on([$this, 'showHelp'], 'help'); } + /** + * Get the name. + * + * @return string + */ public function name(): string { return $this->name; } + /** + * Get the version. + * + * @return string + */ public function version(): string { return $this->version; } + /** + * Get the commands. + * + * @return Command[] + */ public function commands(): array { - return $this->commands; + $commands = $this->commands; + + unset($commands['__default__']); + + return $commands; } + /** + * Get the raw argv. + * + * @return array + */ public function argv(): array { return $this->argv; } - public function command(string $name, string $desc = '', string $alias = '', bool $allowUnknown = false): Command + /** + * Sets or gets the ASCII art logo. + * + * @param string|null $logo + * + * @return string|self + */ + public function logo(string $logo = null) + { + if (\func_num_args() === 0) { + return $this->logo; + } + + $this->logo = $logo; + + return $this; + } + + /** + * Add a command by its name desc alias etc + * + * @param string $name + * @param string $desc + * @param string $alias + * @param bool $allowUnknown + * @param bool $default + * + * @return Command + */ + public function command( + string $name, + string $desc = '', + string $alias = '', + bool $allowUnknown = false, + bool $default = false + ): Command { + $command = new Command($name, $desc, $allowUnknown, $this); + + $this->add($command, $alias, $default); + + return $command; + } + + /** + * Add a prepred command. + * + * @param Command $command + * @param string $alias + * @param bool $default + * + * @return self + */ + public function add(Command $command, string $alias = '', bool $default = false): self + { + $name = $command->name(); + if ($this->commands[$name] ?? $this->aliases[$name] ?? $this->commands[$alias] ?? $this->aliases[$alias] ?? null) { throw new \InvalidArgumentException(\sprintf('Command "%s" already added', $name)); } @@ -78,11 +163,22 @@ public function command(string $name, string $desc = '', string $alias = '', boo $this->aliases[$alias] = $name; } - $command = (new Command($name, $desc, $allowUnknown, $this))->version($this->version)->onExit($this->onExit); + if ($default) { + $this->default = $name; + } - return $this->commands[$name] = $command; + $this->commands[$name] = $command->version($this->version)->onExit($this->onExit)->bind($this); + + return $this; } + /** + * Gets matching command for given argv. + * + * @param array $argv + * + * @return Command + */ public function commandFor(array $argv): Command { $argv += [null, null, null]; @@ -93,9 +189,36 @@ public function commandFor(array $argv): Command // cmd alias ?? $this->commands[$this->aliases[$argv[1]] ?? null] // default. - ?? $this->command; + ?? $this->commands[$this->default]; } + /** + * Gets or sets io. + * + * @param Interactor|null $io + * + * @return Interactor|self + */ + public function io(Interactor $io = null) + { + if ($io || !$this->io) { + $this->io = $io ?? new Interactor; + } + + if (\func_num_args() === 0) { + return $this->io; + } + + return $this; + } + + /** + * Parse the arguments via the matching command but dont execute action.. + * + * @param array $argv Cli arguments/options. + * + * @return Command The matched and parsed command (or default) + */ public function parse(array $argv): Command { $this->argv = $argv; @@ -114,13 +237,41 @@ public function parse(array $argv): Command } } - $command->parse($argv); + return $command->parse($argv); + } - $this->doAction($command); + /** + * Handle the request, invoke action and call exit handler. + * + * @param array $argv + * + * @return mixed + */ + public function handle(array $argv) + { + $io = $this->io(); - return $command; + try { + $exitCode = 0; + $command = $this->parse($argv); + + $this->doAction($command); + } catch (\Throwable $e) { + $exitCode = 255; + $location = 'At file ' . $e->getFile() . '#' . $e->getLine(); + $io->error($e->getMessage(), true)->bgRed($location, true); + } + + return ($this->onExit)($exitCode); } + /** + * Get aliases for given command. + * + * @param Command $command + * + * @return array + */ protected function aliasesFor(Command $command): array { $aliases = [$name = $command->name()]; @@ -135,28 +286,60 @@ protected function aliasesFor(Command $command): array return $aliases; } - public function showHelp(Writer $writer = null) + /** + * Show help of all commands. + * + * @return mixed + */ + public function showHelp() { + $writer = $this->io()->writer(); + $helper = new OutputHelper($writer); + $header = "{$this->name}, version {$this->version}"; $footer = 'Run ` --help` for specific help'; - (new OutputHelper($writer))->showCommandsHelp($this->commands, $header, $footer); + if ($this->logo) { + $writer->write($this->logo, true); + } + + $helper->showCommandsHelp($this->commands(), $header, $footer); return ($this->onExit)(); } + /** + * Invoke command action. + * + * @param Command $command + * + * @return mixed + */ protected function doAction(Command $command) { if (null === $action = $command->action()) { return; } + // Let the command collect more data (if mising or needs confirmation) + $command->interact($this->io()); + $params = []; $values = $command->values(); - foreach ((new \ReflectionFunction($action))->getParameters() as $param) { + + foreach ($this->getActionParameters($action) as $param) { $params[] = $values[$param->getName()] ?? null; } return $action(...$params); } + + protected function getActionParameters(callable $action): array + { + $reflex = \is_array($action) + ? (new \ReflectionClass($action[0]))->getMethod($action[1]) + : new \ReflectionFunction($action); + + return $reflex->getParameters(); + } } From 865737ca6f0ee01a565be644a0d5a2a0e91d4a25 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 20:59:59 +0700 Subject: [PATCH 10/14] docs(readme): update handle()/parse() --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4f0dd3e..c98f397 100644 --- a/README.md +++ b/README.md @@ -136,11 +136,14 @@ $app }) ; -// Maybe it could be named `handle()` or `run()`, but again we keep legacy of `commander.js` +// Parse only parses input but doesnt invoke action $app->parse(['git', 'add', 'path1', 'path2', 'path3', '-f']); + +// Hanlde will do both parse and invoke action. +$app->handle(['git', 'add', 'path1', 'path2', 'path3', '-f']); // Will produce: Add path1, path2, path3 with force -$app->parse(['git', 'co', '-b', 'master-2', '-f']); +$app->handle(['git', 'co', '-b', 'master-2', '-f']); // Will produce: Checkout to new master-2 with force ``` From 34f909ffd14c1641ddf4d8f6c6c923941cde51fb Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 21:00:28 +0700 Subject: [PATCH 11/14] test: add more tests. fix current ones --- tests/ApplicationTest.php | 142 ++++++++++++++++++++++++++++++++---- tests/Input/CommandTest.php | 31 +++++++- 2 files changed, 157 insertions(+), 16 deletions(-) diff --git a/tests/ApplicationTest.php b/tests/ApplicationTest.php index 6af3b84..2403678 100644 --- a/tests/ApplicationTest.php +++ b/tests/ApplicationTest.php @@ -4,11 +4,26 @@ use Ahc\Cli\Application; use Ahc\Cli\Input\Command; -use Ahc\Cli\Output\Writer; +use Ahc\Cli\IO\Interactor; use PHPUnit\Framework\TestCase; class ApplicationTest extends TestCase { + protected static $in = __DIR__ . '/input'; + protected static $ou = __DIR__ . '/output'; + + public function setUp() + { + file_put_contents(static::$in, ''); + file_put_contents(static::$ou, ''); + } + + public function tearDown() + { + unlink(static::$in); + unlink(static::$ou); + } + public function test_new() { $a = $this->newApp('project', '1.0.1'); @@ -70,48 +85,145 @@ public function test_parse() public function test_help() { - $ou = __DIR__ . '/output'; - $a = $this->newApp('git', '0.0.2'); - - $a->command('add', 'stage change', 'a')->arguments(''); - $a->showHelp(new Writer($ou)); + $logo = ' + _ _ + __ _(_) |_ + / _` | | __| + | (_| | | |_ + \__, |_|\__| + |___/ + '; + + $this->newApp('git', '0.0.2') + ->logo($logo) + ->command('add', 'stage change', 'a') + ->arguments('') + ->tap() + ->parse(['git', '--help']); - $out = file_get_contents($ou); + $out = file_get_contents(static::$ou); $this->assertContains('git, version 0.0.2', $out); + $this->assertContains($logo, $out); $this->assertContains('add', $out); $this->assertContains('stage change', $out); - - unlink($ou); } public function test_action() { ($a = $this->newApp('git', '0.0.2')) ->command('add', 'stage change', 'a') - ->arguments('')->action(function ($files) { + ->arguments('') + ->action(function ($files) { echo 'Add ' . implode(' and ', $files); - })->tap($a) + }) + ->tap($a) ->command('config', 'list config', 'c') - ->option('-l --list ', 'list config')->action(function ($list) { + ->option('-l --list ', 'list config') + ->action(function ($list) { echo "Config $list: user.email=user+100@gmail.com"; }); ob_start(); - $a->parse(['git', 'add', 'a.php', 'b.php']); + $a->handle(['git', 'add', 'a.php', 'b.php']); $buffer = ob_get_clean(); $this->assertSame('Add a.php and b.php', $buffer); ob_start(); - $a->parse(['git', 'c', '--list', 'global']); + $a->handle(['git', 'c', '--list', 'global']); $buffer = ob_get_clean(); $this->assertSame('Config global: user.email=user+100@gmail.com', $buffer); } + public function test_no_action() + { + $a = $this->newApp('git', '0.0.2'); + + $a->command('add', 'stage change', 'a')->arguments(''); + + $this->assertFalse($a->handle(['git', 'add', 'a.php', 'b.php'])); + } + + public function test_action_exception() + { + $a = $this->newApp('git', '0.0.2'); + + $a->command('add', 'stage change', 'a')->arguments('')->action(function () { + throw new \InvalidArgumentException('Dummy InvalidArgumentException'); + }); + + $a->handle(['git', 'add', 'a.php', 'b.php']); + + $this->assertContains('Dummy InvalidArgumentException', file_get_contents(static::$ou)); + } + + public function test_array_action() + { + $a = $this->newApp('git', '0.0.2'); + + $this->actionCalled = false; + + $a->command('add', 'stage change', 'a')->arguments('')->action([$this, 'action']); + $a->handle(['git', 'add', 'a.php', 'b.php']); + + $this->assertTrue($this->actionCalled); + } + + public function action(array $files) + { + $this->actionCalled = true; + } + + public function test_logo() + { + $a = $this->newApp('test', '0.0.2'); + + $this->assertSame($a, $a->logo($logo = ' + | |_ ___ ___| |_ + | __/ _ \/ __| __| + | || __/\__ \ |_ + \__\___||___/\__| + ')); + + $this->assertSame($logo, $a->logo()); + } + + public function test_add() + { + $a = $this->newApp('test', '0.0.1-test'); + + $this->assertSame($a, $a->add(new Command('cmd'), 'c', true)); + $this->assertSame('cmd', $a->commandFor(['test', 'cmd'])->name()); + } + + public function test_add_dup() + { + $a = $this->newApp('test', '0.0.1-test'); + + $this->expectException(\InvalidArgumentException::class); + + $a->add(new Command('cmd'), 'cm'); + $a->add(new Command('cm')); + } + + public function test_io() + { + $a = $this->newApp('test', '0.0.1-test'); + + $this->assertInstanceOf(Interactor::class, $oio = $a->io()); + + $a->io(new Interactor); + + $this->assertInstanceOf(Interactor::class, $a->io()); + $this->assertNotSame($oio, $a->io()); + } + protected function newApp(string $name, string $version = '') { - return new Application($name, $version ?: '0.0.1', function () { + $app = new Application($name, $version ?: '0.0.1', function () { return false; }); + + return $app->io(new Interactor(static::$in, static::$ou)); } } diff --git a/tests/Input/CommandTest.php b/tests/Input/CommandTest.php index 9adb0c6..babff86 100644 --- a/tests/Input/CommandTest.php +++ b/tests/Input/CommandTest.php @@ -62,7 +62,7 @@ public function test_arguments_variadic_not_last() $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Only last argument can be variadic'); - $p = $this->newCommand()->arguments(' [env]'); + $p = $this->newCommand()->arguments('[paths...]')->argument('[env]', 'Env'); } public function test_arguments_with_options() @@ -163,6 +163,26 @@ public function test_bool_options() $this->assertTrue($p->that); } + public function test_user_options() + { + $p = $this->newCommand(); + + $this->assertEmpty($p->userOptions()); + + $p = $this->newCommand()->option('-u --user', 'User'); + + $this->assertNotEmpty($o = $p->userOptions()); + $this->assertCount(1, $o); + $this->assertSame('user', reset($o)->name()); + } + + public function test_usage() + { + $p = $this->newCommand()->usage('Usage: $ cmd [...]'); + + $this->assertSame('Usage: $ cmd [...]', $p->usage()); + } + public function test_event() { $p = $this->newCommand()->option('--hello')->on(function () { @@ -208,6 +228,15 @@ public function test_app_tap() $this->assertInstanceOf(Application::class, $c->app()); } + public function test_bind() + { + $c = $this->newCommand()->bind(new Application('app')); + $this->assertInstanceOf(Application::class, $c->app()); + + $c = $this->newCommand()->bind(null); + $this->assertNull($c->app()); + } + protected function newCommand(string $version = '0.0.1', string $desc = '', bool $allowUnknown = false, $app = null) { $p = new Command('cmd', $desc, $allowUnknown, $app); From d9357fb5fe7852300791f721f0391abfe4831515 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 21:05:31 +0700 Subject: [PATCH 12/14] docs: typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c98f397..e48ac21 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ $app // Parse only parses input but doesnt invoke action $app->parse(['git', 'add', 'path1', 'path2', 'path3', '-f']); -// Hanlde will do both parse and invoke action. +// Handle will do both parse and invoke action. $app->handle(['git', 'add', 'path1', 'path2', 'path3', '-f']); // Will produce: Add path1, path2, path3 with force From e5f9181d0588a462341970cf0adcb8ff9d163da8 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 21:05:56 +0700 Subject: [PATCH 13/14] feat(command): support exit code --- src/Input/Command.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Input/Command.php b/src/Input/Command.php index 33a4551..5bb3847 100644 --- a/src/Input/Command.php +++ b/src/Input/Command.php @@ -80,8 +80,8 @@ protected function defaults(): self }); // @codeCoverageIgnoreStart - $this->onExit(function () { - exit(0); + $this->onExit(function ($exitCode = 0) { + exit($exitCode); }); // @codeCoverageIgnoreEnd @@ -315,7 +315,7 @@ public function showHelp() ->showArgumentsHelp($this->allArguments()) ->showOptionsHelp($this->allOptions(), '', 'Legend: [optional]'); - return $this->emit('_exit'); + return $this->emit('_exit', 0); } /** @@ -327,7 +327,7 @@ public function showVersion() { $this->writer()->bold($this->_version, true); - return $this->emit('_exit'); + return $this->emit('_exit', 0); } /** From c92f7d884f68967996b3b3f66a2a4ead70360c37 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 12 Jul 2018 21:10:41 +0700 Subject: [PATCH 14/14] style(cs): lint --- src/Application.php | 20 +++++++++----------- src/Output/Writer.php | 4 ++-- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/Application.php b/src/Application.php index e81a319..9385e4e 100644 --- a/src/Application.php +++ b/src/Application.php @@ -3,9 +3,8 @@ namespace Ahc\Cli; use Ahc\Cli\Helper\OutputHelper; -use Ahc\Cli\IO\Interactor; use Ahc\Cli\Input\Command; -use Ahc\Cli\Output\Writer; +use Ahc\Cli\IO\Interactor; /** * A cli application. @@ -117,13 +116,13 @@ public function logo(string $logo = null) } /** - * Add a command by its name desc alias etc + * Add a command by its name desc alias etc. * - * @param string $name - * @param string $desc - * @param string $alias - * @param bool $allowUnknown - * @param bool $default + * @param string $name + * @param string $desc + * @param string $alias + * @param bool $allowUnknown + * @param bool $default * * @return Command */ @@ -133,8 +132,7 @@ public function command( string $alias = '', bool $allowUnknown = false, bool $default = false - ): Command - { + ): Command { $command = new Command($name, $desc, $allowUnknown, $this); $this->add($command, $alias, $default); @@ -175,7 +173,7 @@ public function add(Command $command, string $alias = '', bool $default = false) /** * Gets matching command for given argv. * - * @param array $argv + * @param array $argv * * @return Command */ diff --git a/src/Output/Writer.php b/src/Output/Writer.php index 8059e01..4d1c476 100644 --- a/src/Output/Writer.php +++ b/src/Output/Writer.php @@ -91,8 +91,8 @@ public function write(string $text, bool $eol = false): self /** * Really write to the stream. * - * @param string $text - * @param bool $error + * @param string $text + * @param bool $error * * @return self */