From 11d707e3c5c11b9960b5926d052b19f280eec017 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 28 Jun 2018 22:18:35 +0700 Subject: [PATCH 01/36] docs: readme --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..11da2d0 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +## adhocore/cli + +Command Line Interface utilities and helpers for PHP. + +... a work in progress ... + +[![Latest Version](https://img.shields.io/github/release/adhocore/cli.svg?style=flat-square)](https://github.com/adhocore/cli/releases) +[![Travis Build](https://img.shields.io/travis/adhocore/cli/master.svg?style=flat-square)](https://travis-ci.org/adhocore/cli?branch=master) +[![Scrutinizer CI](https://img.shields.io/scrutinizer/g/adhocore/cli.svg?style=flat-square)](https://scrutinizer-ci.com/g/adhocore/cli/?branch=master) +[![Codecov branch](https://img.shields.io/codecov/c/github/adhocore/cli/master.svg?style=flat-square)](https://codecov.io/gh/adhocore/cli) +[![StyleCI](https://styleci.io/repos/139012552/shield)](https://styleci.io/repos/139012552) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) + +- Command line arguments parsing made easy +- Inspired by nodejs [commander](https://github.com/tj/commander.js) (thanks tj) +- For PHP5.6 or new and for good + +(*PS: It is just argv parser and not ditto commander, so spawning new commands or actions are not supported*) + +**Bonus**: Text coloring for the cli + +## Installation +```bash +composer require adhocore/cli +``` + +## Usage +```php +# coming soon +``` From 3aec08ff7308b8d60e9f0643e776c5a5b54e0cbc Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 28 Jun 2018 22:19:39 +0700 Subject: [PATCH 02/36] chore: dot files- editorconfig/gitignore/travis --- .editorconfig | 17 +++++++++++++++++ .gitignore | 7 +++++++ .travis.yml | 19 +++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .travis.yml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8d74982 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +; http://editorconfig.org +; +; Sublime: https://github.com/sindresorhus/editorconfig-sublime +; Phpstorm: https://plugins.jetbrains.com/plugin/7294-editorconfig + +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[{*.md,*.php,composer.json,composer.lock}] +indent_size = 4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6446219 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# standards +/.cache/ +/.env +/.idea/ +/vendor/ +composer.lock +*.local.* diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..db6e530 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +language: php + +php: + - 5.6 + - 7.0 + - 7.1 + - 7.2 + +install: + - composer install --prefer-dist + +before_script: + - for P in src tests; do find $P -type f -name '*.php' -exec php -l {} \;; done + +script: + - vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml + +after_success: + - bash <(curl -s https://codecov.io/bash) From fb4348c5ca98c0b50bdd01234efd3fe7273d508b Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 28 Jun 2018 22:22:30 +0700 Subject: [PATCH 03/36] chore: composer.json --- composer.json | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 composer.json diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..005f527 --- /dev/null +++ b/composer.json @@ -0,0 +1,30 @@ +{ + "name": "adhocore/cli", + "description": "Command line interface library for PHP", + "type": "library", + "keywords": [ + "php", "command", "argv-parser", "cli", "cli-color", "cli-action" + ], + "license": "MIT", + "authors": [ + { + "name": "Jitendra Adhikari", + "email": "jiten.adhikary@gmail.com" + } + ], + "autoload": { + "psr-4": { + "Ahc\\Cli\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Ahc\\Cli\\Test\\": "tests/" + } + }, + "require": { + }, + "require-dev": { + "phpunit/phpunit": "^6.0.0" + } +} From 57fe909f6cdad2e09258f62832aed563e1a3f3e7 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 28 Jun 2018 22:23:05 +0700 Subject: [PATCH 04/36] test: init- config/bootstrap --- phpunit.xml.dist | 24 ++++++++++++++++++++++++ tests/bootstrap.php | 3 +++ 2 files changed, 27 insertions(+) create mode 100644 phpunit.xml.dist create mode 100644 tests/bootstrap.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..dc7f090 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,24 @@ + + + + + + ./tests/ + + + + + ./src + + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..6c8c4f5 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,3 @@ + Date: Thu, 28 Jun 2018 22:23:36 +0700 Subject: [PATCH 05/36] feat: cli colorizer --- src/Color.php | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/Color.php diff --git a/src/Color.php b/src/Color.php new file mode 100644 index 0000000..a4989fc --- /dev/null +++ b/src/Color.php @@ -0,0 +1,122 @@ + + * @license MIT + * + * @link https://github.com/adhocore/cli + */ +class Color +{ + const FG_RED = 31; + const FG_GREEN = 32; + const FG_YELLOW = 33; + const FG_BLUE = 36; + // @todo + + protected static $format = "\033[:bold:;:fg:;:bg:m:text:\033[0m"; + + protected static $styles = []; + + protected static $muted = false; + + public static function error($text, array $style = [], $eol = false) + { + static::line($text, ['fg' => static::FG_RED] + $style, $eol); + } + + public static function info($text, array $style = [], $eol = false) + { + static::line($text, ['fg' => static::FG_BLUE] + $style, $eol); + } + + public static function warn($text, array $style = [], $eol = false) + { + static::line($text, ['fg' => static::FG_YELLOW] + $style, $eol); + } + + public static function comment($text, array $style = [], $eol = false) + { + static::line($text, ['fg' => static::FG_RED] + $style, $eol); + } + + public static function line($text, array $style = [], $eol = false) + { + if (static::$muted) { + return; + } + + $style += ['bg' => null, 'fg' => 37, 'bold' => 0]; + + $format = $style['bg'] === null + ? \str_replace(';:bg:', '', static::$format) + : static::$format; + + echo \strtr($format, [ + ':bold:' => (int) $style['bold'], + ':fg:' => (int) $style['fg'], + ':bg:' => (int) $style['bg'], + ':text:' => (string) $text, + ]); + + if ($eol) { + static::eol(); + } + } + + public static function eol() + { + if (!static::$muted) { + echo PHP_EOL; + } + } + + public function style($name, array $style) + { + $allow = ['fg' => true, 'bg' => true, 'bold' => true]; + $style = \array_intersect_key($style, $allow); + + if (empty($style)) { + throw new \InvalidArgumentException('Trying to set empty or invalid style'); + } + + if (isset(static::$styles[$name]) || \method_exists(static::class, $name)) { + throw new \InvalidArgumentException('Trying to define existing style'); + } + + static::$styles[$name] = $style; + } + + public static function __callStatic($name, $arguments) + { + if (empty($arguments[0])) { + throw new \InvalidArgumentException('Text required'); + } + + list($text, $style, $eol) = $arguments + ['', [], false]; + + if (\substr($name, 0, 4) === 'bold') { + $name = \lcfirst(\substr($name, 4)); + static::{$name}($text, ['bold' => true] + $style, $eol); + + return; + } + + if (!isset(static::$styles[$name])) { + throw new \InvalidArgumentException(\sprintf('Style %s not defined', $name)); + } + + $style = static::$styles[$name]; + + static::line($text, $style, $eol); + } + + public static function mute($muted = true) + { + static::$muted = $muted; + } +} From e2a57c8d607483bb58f422872febeb538d013445 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 28 Jun 2018 22:23:57 +0700 Subject: [PATCH 06/36] feat: cli argument option manager --- src/Option.php | 123 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/Option.php diff --git a/src/Option.php b/src/Option.php new file mode 100644 index 0000000..c529ff4 --- /dev/null +++ b/src/Option.php @@ -0,0 +1,123 @@ + + * @license MIT + * + * @link https://github.com/adhocore/cli + */ +class Option +{ + protected $short; + + protected $long; + + protected $desc; + + protected $default; + + protected $required = true; + + protected $optional = false; + + protected $variadic = false; + + protected $filter; + + protected $collect = []; + + public function __construct($cmd, $desc = '', $default = null, callable $filter = null) + { + $this->desc = $desc; + $this->default = $default; + $this->filter = $filter; + $this->required = \strpos($cmd, '<') !== false; + $this->optional = \strpos($cmd, '[') !== false; + + if ($this->variadic = \strpos($cmd, '...') !== false) { + $this->default = (array) $this->default; + } + + $this->parse($cmd); + } + + protected function parse($cmd) + { + if (\strpos($cmd, '-with-') !== false) { + $this->default = false; + } elseif (\strpos($cmd, '-no-') !== false) { + $this->default = true; + } + + $parts = \preg_split('/[\s,\|]+/', $cmd); + + $this->short = $this->long = $parts[0]; + if (isset($parts[1])) { + $this->long = $parts[1]; + } + } + + public function long() + { + return $this->long; + } + + public function short() + { + return $this->short; + } + + public function name() + { + return \str_replace(['--', 'no-', 'with-'], '', $this->long); + } + + public function attributeName() + { + $words = \str_replace('-', ' ', $this->name()); + + $words = \str_replace(' ', '', \ucwords($words)); + + return \lcfirst($words); + } + + public function is($arg) + { + return $this->short === $arg || $this->long === $arg; + } + + public function required() + { + return $this->required; + } + + public function variadic() + { + return $this->variadic; + } + + public function default() + { + return $this->default; + } + + public function bool() + { + return \preg_match('/\-no|\-with/', $this->long) > 0; + } + + public function filter($raw) + { + if ($this->filter) { + $callback = $this->filter; + + return $callback($raw); + } + + return $raw; + } +} From 00ee163fd2fd5ae1ea6b1a223250807e4246f5c8 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 28 Jun 2018 22:24:24 +0700 Subject: [PATCH 07/36] feat: wip: cli argv parser --- src/ArgvParser.php | 279 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 src/ArgvParser.php diff --git a/src/ArgvParser.php b/src/ArgvParser.php new file mode 100644 index 0000000..263ba34 --- /dev/null +++ b/src/ArgvParser.php @@ -0,0 +1,279 @@ + + * @license MIT + * + * @link https://github.com/adhocore/cli + */ +class ArgvParser +{ + protected $_version; + + protected $_lastOption; + + protected $_name; + + protected $_desc; + + protected $_options = []; + + protected $_values = []; + + protected $_args = []; + + protected $_events = []; + + protected $_allowUnknown = false; + + protected $_wasVariadic = false; + + public function __construct($name, $desc = null, $allowUnknown = false) + { + $this->_name = $name; + $this->_desc = $desc; + $this->_allowUnknown = $allowUnknown; + + $this->addDefaultOptions(); + } + + protected function addDefaultOptions() + { + $this->option('-h, --help', 'Show help')->on([$this, 'showHelp']); + $this->option('-V, --version', 'Show version')->on([$this, 'showVersion']); + } + + public function version($version) + { + $this->_version = $version; + + return $this; + } + + public function option($cmd, $desc = '', callable $filter = null, $default = null) + { + $option = new Option($cmd, $desc, $default, $filter); + + if (isset($this->_options[$option->long()])) { + throw new \InvalidArgumentException( + \sprintf('The option %s is already registered', $option->long()) + ); + } + + $this->_values[$option->attributeName()] = $option->default(); + $this->_options[$option->long()] = $option; + + return $this; + } + + public function on(callable $fn) + { + \end($this->_options); + + $this->_events[\key($this->_options)] = $fn; + + return $this; + } + + public function parse(array $argv) + { + \array_shift($argv); + + $argv = $this->normalize($argv); + $count = \count($argv); + + for ($i = 0; $i < $count; $i++) { + list($arg, $nextArg) = [$argv[$i], isset($argv[$i + 1]) ? $argv[$i + 1] : null]; + + if ($arg[0] !== '-' || !empty($literal) || ($literal = $arg === '--')) { + $this->parseArgs($arg); + } else { + $i += (int) $this->parseOptions($arg, $nextArg, $i); + } + } + + return $this->validate(); + } + + protected function parseArgs($arg) + { + if ($this->_wasVariadic) { + $this->_values[$this->_lastOption->attributeName()][] = $arg; + } else { + $this->_args[] = $arg; + } + } + + protected function parseOptions($arg, $nextArg, &$i) + { + $value = \substr($nextArg, 0, 1) === '-' ? null : $nextArg; + $isValue = $value !== null; + + $this->_lastOption = $option = $this->optionFor($arg); + $this->_wasVariadic = $option ? $option->variadic() : false; + + if (!$option) { + $this->handleUnknown($arg, $value); + + return $isValue; + } + + $this->emit($option->long()); + $this->setValue($option, $value); + + return $isValue; + } + + protected function handleUnknown($arg, $value) + { + if ($this->_allowUnknown) { + $this->_values[$arg] = $value; + + return; + } + + // Has some value, error! + if ($this->_values) { + throw new \RuntimeException( + \sprintf("Option %s not registered", $arg) + ); + } + + // Has no value, show help! + return $this->showHelp(); + } + + protected function setValue(Option $option, $value) + { + $name = $option->attributeName(); + + if (null === $value && null !== $this->_values[$name]) { + return; + } + + $this->_values[$name] = $this->prepareValue($option, $value); + } + + protected function prepareValue(Option $option, $value) + { + if ($option->bool()) { + return !$option->default(); + } + + if ($option->variadic()) { + return (array) $value; + } + + return null === $value ? null : $option->filter($value); + } + + protected function validate() + { + foreach ($this->_options as $option) { + if (!$option->required()) { + continue; + } + + $value = $this->_values[$option->attributeName()]; + + if (null === $value || [] === $value) { + throw new \RuntimeException( + \sprintf('Option %s|%s is required', $option->short(), $option->long()) + ); + } + } + + return $this; + } + + public function values($withDefaults = true) + { + $values = $this->_values; + + if (!$withDefaults) { + unset($values['help'], $values['version']); + } + + return $values; + } + + public function args() + { + return $this->_args; + } + + public function __get($key) + { + return isset($this->_values[$key]) ? $this->_values[$key] : null; + } + + protected function showHelp() + { + echo "{$this->_name}, version {$this->_version}\n"; + + exit('help'); + } + + protected function showVersion() + { + echo "{$this->_name}, version {$this->_version}\n"; + + exit(0); + } + + protected function normalize(array $args) + { + $normalized = []; + + foreach ($args as $arg) { + if (\preg_match('/^\-\w{2,}/', $arg)) { + $normalized = \array_merge($normalized, $this->splitShort($arg)); + } elseif (\preg_match('/^\-\-\w{2,}\=/', $arg)) { + $normalized = \array_merge($normalized, explode('=', $arg)); + } else { + $normalized[] = $arg; + } + } + + return $normalized; + } + + protected function splitShort($arg) + { + $args = \str_split(\substr($arg, 1)); + + return \array_map(function ($a) { + return "-$a"; + }, $args); + } + + protected function optionFor($arg) + { + if (isset($this->_options[$arg])) { + return $this->_options[$arg]; + } + + foreach ($this->_options as $option) { + if ($option->is($arg)) { + return $option; + } + } + + return null; + } + + protected function emit($event) + { + if (empty($this->_events[$event])) { + return; + } + + $callback = $this->_events[$event]; + + $callback(); + } +} From 8383917adaeb141d23753e882ec5a821c232cb6a Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 28 Jun 2018 22:24:45 +0700 Subject: [PATCH 08/36] test: fixture --- tests/fixture.php | 103 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/fixture.php diff --git a/tests/fixture.php b/tests/fixture.php new file mode 100644 index 0000000..f2344a1 --- /dev/null +++ b/tests/fixture.php @@ -0,0 +1,103 @@ + [ + 'space req' => [ + 'cmd' => '-v --virtual ', + 'expect' => [ + 'long' => '--virtual', + 'short' => '-v', + 'required' => true, + 'variadic' => false, + 'name' => 'virtual', + 'aname' => 'virtual', + ], + ], + 'comma opt' => [ + 'cmd' => '-f,--fruit [opt]', + 'expect' => [ + 'long' => '--fruit', + 'short' => '-f', + 'required' => false, + 'variadic' => false, + 'name' => 'fruit', + 'aname' => 'fruit', + ], + ], + 'pipe ...' => [ + 'cmd' => '-a|--apple [opt...]', + 'expect' => [ + 'long' => '--apple', + 'short' => '-a', + 'required' => false, + 'variadic' => true, + 'name' => 'apple', + 'aname' => 'apple', + 'bool' => false, + ], + ], + '--no' => [ + 'cmd' => '-n|--no-shit', + 'expect' => [ + 'long' => '--no-shit', + 'short' => '-n', + 'required' => false, + 'variadic' => false, + 'name' => 'shit', + 'aname' => 'shit', + 'bool' => true, + ], + ], + '--with' => [ + 'cmd' => '-w|--with-this', + 'expect' => [ + 'long' => '--with-this', + 'short' => '-w', + 'required' => false, + 'variadic' => false, + 'name' => 'this', + 'aname' => 'this', + 'default' => false, + 'bool' => true, + ], + ], + 'camel case' => [ + 'cmd' => '-C|--camel-case', + 'expect' => [ + 'long' => '--camel-case', + 'short' => '-C', + 'required' => false, + 'variadic' => false, + 'name' => 'camel-case', + 'aname' => 'camelCase', + 'default' => false, + ], + ], + ], + 'argvs' => [ + [ + 'argv' => [], + 'throws' => [\RuntimeException::class, 'Option -v|--virtual is required'], + ], + [ + 'argv' => [''], + 'throws' => [\RuntimeException::class, 'Option -v|--virtual is required'], + ], + [ + 'argv' => ['-x', 1], + 'throws' => [\RuntimeException::class, 'Option -v|--virtual is required'], + ], + [ + 'argv' => ['-x', 1], + 'throws' => [\RuntimeException::class, 'Option -v|--virtual is required'], + ], + [ + 'argv' => ['-v'], + 'throws' => [\RuntimeException::class, 'Option -v|--virtual is required'], + ], + [ + 'argv' => ['--virtual'], + 'throws' => [\RuntimeException::class, 'Option -v|--virtual is required'], + ] + ], +]; From 8928c70040559a6c3c1fd3a4a096ed9df133bfd1 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 28 Jun 2018 22:24:59 +0700 Subject: [PATCH 09/36] test: options test --- tests/OptionTest.php | 71 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/OptionTest.php diff --git a/tests/OptionTest.php b/tests/OptionTest.php new file mode 100644 index 0000000..e2756cd --- /dev/null +++ b/tests/OptionTest.php @@ -0,0 +1,71 @@ + $o->default()]; + } + if (isset($expect['bool'])) { + $more += ['bool' => $o->bool()]; + } + + $this->assertEquals($expect, [ + 'long' => $o->long(), + 'short' => $o->short(), + 'required' => $o->required(), + 'variadic' => $o->variadic(), + 'name' => $o->name(), + 'aname' => $o->attributeName(), + ] + $more); + } + + public function test_is() + { + $o = new Option('-a --age'); + + $this->assertTrue($o->is('-a')); + $this->assertTrue($o->is('--age')); + + $this->assertFalse($o->is('--rage')); + $this->assertFalse($o->is('--k')); + $this->assertFalse($o->is('a')); + $this->assertFalse($o->is('age')); + } + + public function test_filter() + { + $o = new Option('-a --age', 'Age', 18, 'intval'); + + $this->assertSame(18, $o->default()); + $this->assertSame(10, $o->filter('10')); + + $in = 'apple'; + $o = new Option('-f, --fruit', 'Age', 'orange', 'strtoupper'); + + $this->assertNotSame($o->filter($in), $in); + $this->assertSame('APPLE', $o->filter($in)); + + $this->assertSame('orange', $o->default(), 'default shouldnt be filtered'); + + $o = new Option('--long-only'); + $this->assertSame($r = rand(), $o->filter($r)); + } + + public function data() + { + $f = require __DIR__ . '/fixture.php'; + + return $f['options']; + } +} From 863ab857d1b043e02041eb4953393ae6b9db9c81 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 28 Jun 2018 22:25:25 +0700 Subject: [PATCH 10/36] test: argv parser test [wip] --- tests/ArgvParserTest.php | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/ArgvParserTest.php diff --git a/tests/ArgvParserTest.php b/tests/ArgvParserTest.php new file mode 100644 index 0000000..0bee513 --- /dev/null +++ b/tests/ArgvParserTest.php @@ -0,0 +1,41 @@ +version('0.0.' . rand(1, 10)); + + $data = $this->data(); + foreach ($data['options'] as $option) { + $p->option($option['cmd']); + } + + foreach ($data['argvs'] as $argv) { + if (isset($argv['throws'])) { + $this->expectException($argv['throws'][0]); + $this->expectExceptionMessage($argv['throws'][1]); + } + + $values = $p->parse($argv['argv']); + + $argv += ['expect' => []]; + + foreach ($argv['expect'] as $key => $expect) { + $this->assertSame($expect, $values[$key]); + } + } + } + + public function data() + { + return require __DIR__ . '/fixture.php'; + } +} From 21611ff329e8a4e379d63478dc7880b646436173 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 28 Jun 2018 15:28:09 +0000 Subject: [PATCH 11/36] Apply fixes from StyleCI --- src/ArgvParser.php | 28 +++++++++++++--------------- src/Color.php | 8 ++++---- src/Option.php | 6 +++--- tests/ArgvParserTest.php | 4 ++-- tests/OptionTest.php | 4 ++-- tests/bootstrap.php | 2 +- tests/fixture.php | 38 +++++++++++++++++++------------------- 7 files changed, 44 insertions(+), 46 deletions(-) diff --git a/src/ArgvParser.php b/src/ArgvParser.php index 263ba34..23bd136 100644 --- a/src/ArgvParser.php +++ b/src/ArgvParser.php @@ -20,22 +20,22 @@ class ArgvParser protected $_desc; - protected $_options = []; + protected $_options = []; - protected $_values = []; + protected $_values = []; - protected $_args = []; + protected $_args = []; - protected $_events = []; + protected $_events = []; protected $_allowUnknown = false; - protected $_wasVariadic = false; + protected $_wasVariadic = false; public function __construct($name, $desc = null, $allowUnknown = false) { - $this->_name = $name; - $this->_desc = $desc; + $this->_name = $name; + $this->_desc = $desc; $this->_allowUnknown = $allowUnknown; $this->addDefaultOptions(); @@ -65,7 +65,7 @@ public function option($cmd, $desc = '', callable $filter = null, $default = nul } $this->_values[$option->attributeName()] = $option->default(); - $this->_options[$option->long()] = $option; + $this->_options[$option->long()] = $option; return $this; } @@ -83,8 +83,8 @@ public function parse(array $argv) { \array_shift($argv); - $argv = $this->normalize($argv); - $count = \count($argv); + $argv = $this->normalize($argv); + $count = \count($argv); for ($i = 0; $i < $count; $i++) { list($arg, $nextArg) = [$argv[$i], isset($argv[$i + 1]) ? $argv[$i + 1] : null]; @@ -110,10 +110,10 @@ protected function parseArgs($arg) protected function parseOptions($arg, $nextArg, &$i) { - $value = \substr($nextArg, 0, 1) === '-' ? null : $nextArg; + $value = \substr($nextArg, 0, 1) === '-' ? null : $nextArg; $isValue = $value !== null; - $this->_lastOption = $option = $this->optionFor($arg); + $this->_lastOption = $option = $this->optionFor($arg); $this->_wasVariadic = $option ? $option->variadic() : false; if (!$option) { @@ -139,7 +139,7 @@ protected function handleUnknown($arg, $value) // Has some value, error! if ($this->_values) { throw new \RuntimeException( - \sprintf("Option %s not registered", $arg) + \sprintf('Option %s not registered', $arg) ); } @@ -262,8 +262,6 @@ protected function optionFor($arg) return $option; } } - - return null; } protected function emit($event) diff --git a/src/Color.php b/src/Color.php index a4989fc..47e4707 100644 --- a/src/Color.php +++ b/src/Color.php @@ -12,17 +12,17 @@ */ class Color { - const FG_RED = 31; - const FG_GREEN = 32; + const FG_RED = 31; + const FG_GREEN = 32; const FG_YELLOW = 33; - const FG_BLUE = 36; + const FG_BLUE = 36; // @todo protected static $format = "\033[:bold:;:fg:;:bg:m:text:\033[0m"; protected static $styles = []; - protected static $muted = false; + protected static $muted = false; public static function error($text, array $style = [], $eol = false) { diff --git a/src/Option.php b/src/Option.php index c529ff4..c553da5 100644 --- a/src/Option.php +++ b/src/Option.php @@ -32,9 +32,9 @@ class Option public function __construct($cmd, $desc = '', $default = null, callable $filter = null) { - $this->desc = $desc; - $this->default = $default; - $this->filter = $filter; + $this->desc = $desc; + $this->default = $default; + $this->filter = $filter; $this->required = \strpos($cmd, '<') !== false; $this->optional = \strpos($cmd, '[') !== false; diff --git a/tests/ArgvParserTest.php b/tests/ArgvParserTest.php index 0bee513..3412b41 100644 --- a/tests/ArgvParserTest.php +++ b/tests/ArgvParserTest.php @@ -11,7 +11,7 @@ public function test_new() { $p = new ArgvParser('ArgvParser'); - $p->version('0.0.' . rand(1, 10)); + $p->version('0.0.'.rand(1, 10)); $data = $this->data(); foreach ($data['options'] as $option) { @@ -36,6 +36,6 @@ public function test_new() public function data() { - return require __DIR__ . '/fixture.php'; + return require __DIR__.'/fixture.php'; } } diff --git a/tests/OptionTest.php b/tests/OptionTest.php index e2756cd..ecb67a8 100644 --- a/tests/OptionTest.php +++ b/tests/OptionTest.php @@ -51,7 +51,7 @@ public function test_filter() $this->assertSame(10, $o->filter('10')); $in = 'apple'; - $o = new Option('-f, --fruit', 'Age', 'orange', 'strtoupper'); + $o = new Option('-f, --fruit', 'Age', 'orange', 'strtoupper'); $this->assertNotSame($o->filter($in), $in); $this->assertSame('APPLE', $o->filter($in)); @@ -64,7 +64,7 @@ public function test_filter() public function data() { - $f = require __DIR__ . '/fixture.php'; + $f = require __DIR__.'/fixture.php'; return $f['options']; } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6c8c4f5..e91cc12 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,3 +1,3 @@ [ 'space req' => [ - 'cmd' => '-v --virtual ', + 'cmd' => '-v --virtual ', 'expect' => [ - 'long' => '--virtual', - 'short' => '-v', + 'long' => '--virtual', + 'short' => '-v', 'required' => true, 'variadic' => false, 'name' => 'virtual', @@ -14,10 +14,10 @@ ], ], 'comma opt' => [ - 'cmd' => '-f,--fruit [opt]', + 'cmd' => '-f,--fruit [opt]', 'expect' => [ - 'long' => '--fruit', - 'short' => '-f', + 'long' => '--fruit', + 'short' => '-f', 'required' => false, 'variadic' => false, 'name' => 'fruit', @@ -25,10 +25,10 @@ ], ], 'pipe ...' => [ - 'cmd' => '-a|--apple [opt...]', + 'cmd' => '-a|--apple [opt...]', 'expect' => [ - 'long' => '--apple', - 'short' => '-a', + 'long' => '--apple', + 'short' => '-a', 'required' => false, 'variadic' => true, 'name' => 'apple', @@ -37,10 +37,10 @@ ], ], '--no' => [ - 'cmd' => '-n|--no-shit', + 'cmd' => '-n|--no-shit', 'expect' => [ - 'long' => '--no-shit', - 'short' => '-n', + 'long' => '--no-shit', + 'short' => '-n', 'required' => false, 'variadic' => false, 'name' => 'shit', @@ -49,10 +49,10 @@ ], ], '--with' => [ - 'cmd' => '-w|--with-this', + 'cmd' => '-w|--with-this', 'expect' => [ - 'long' => '--with-this', - 'short' => '-w', + 'long' => '--with-this', + 'short' => '-w', 'required' => false, 'variadic' => false, 'name' => 'this', @@ -62,10 +62,10 @@ ], ], 'camel case' => [ - 'cmd' => '-C|--camel-case', + 'cmd' => '-C|--camel-case', 'expect' => [ - 'long' => '--camel-case', - 'short' => '-C', + 'long' => '--camel-case', + 'short' => '-C', 'required' => false, 'variadic' => false, 'name' => 'camel-case', @@ -98,6 +98,6 @@ [ 'argv' => ['--virtual'], 'throws' => [\RuntimeException::class, 'Option -v|--virtual is required'], - ] + ], ], ]; From 768cb3fedba25713a301784470276491952a7c7e Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Thu, 28 Jun 2018 15:34:29 +0000 Subject: [PATCH 12/36] Apply fixes from StyleCI --- src/ArgvParser.php | 12 ++++++------ src/Color.php | 6 +++--- src/Option.php | 6 +++--- tests/ArgvParserTest.php | 4 ++-- tests/OptionTest.php | 4 ++-- tests/bootstrap.php | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/ArgvParser.php b/src/ArgvParser.php index 23bd136..ee26a9a 100644 --- a/src/ArgvParser.php +++ b/src/ArgvParser.php @@ -34,8 +34,8 @@ class ArgvParser public function __construct($name, $desc = null, $allowUnknown = false) { - $this->_name = $name; - $this->_desc = $desc; + $this->_name = $name; + $this->_desc = $desc; $this->_allowUnknown = $allowUnknown; $this->addDefaultOptions(); @@ -65,7 +65,7 @@ public function option($cmd, $desc = '', callable $filter = null, $default = nul } $this->_values[$option->attributeName()] = $option->default(); - $this->_options[$option->long()] = $option; + $this->_options[$option->long()] = $option; return $this; } @@ -83,7 +83,7 @@ public function parse(array $argv) { \array_shift($argv); - $argv = $this->normalize($argv); + $argv = $this->normalize($argv); $count = \count($argv); for ($i = 0; $i < $count; $i++) { @@ -110,10 +110,10 @@ protected function parseArgs($arg) protected function parseOptions($arg, $nextArg, &$i) { - $value = \substr($nextArg, 0, 1) === '-' ? null : $nextArg; + $value = \substr($nextArg, 0, 1) === '-' ? null : $nextArg; $isValue = $value !== null; - $this->_lastOption = $option = $this->optionFor($arg); + $this->_lastOption = $option = $this->optionFor($arg); $this->_wasVariadic = $option ? $option->variadic() : false; if (!$option) { diff --git a/src/Color.php b/src/Color.php index 47e4707..2e3b706 100644 --- a/src/Color.php +++ b/src/Color.php @@ -12,10 +12,10 @@ */ class Color { - const FG_RED = 31; - const FG_GREEN = 32; + const FG_RED = 31; + const FG_GREEN = 32; const FG_YELLOW = 33; - const FG_BLUE = 36; + const FG_BLUE = 36; // @todo protected static $format = "\033[:bold:;:fg:;:bg:m:text:\033[0m"; diff --git a/src/Option.php b/src/Option.php index c553da5..c529ff4 100644 --- a/src/Option.php +++ b/src/Option.php @@ -32,9 +32,9 @@ class Option public function __construct($cmd, $desc = '', $default = null, callable $filter = null) { - $this->desc = $desc; - $this->default = $default; - $this->filter = $filter; + $this->desc = $desc; + $this->default = $default; + $this->filter = $filter; $this->required = \strpos($cmd, '<') !== false; $this->optional = \strpos($cmd, '[') !== false; diff --git a/tests/ArgvParserTest.php b/tests/ArgvParserTest.php index 3412b41..0bee513 100644 --- a/tests/ArgvParserTest.php +++ b/tests/ArgvParserTest.php @@ -11,7 +11,7 @@ public function test_new() { $p = new ArgvParser('ArgvParser'); - $p->version('0.0.'.rand(1, 10)); + $p->version('0.0.' . rand(1, 10)); $data = $this->data(); foreach ($data['options'] as $option) { @@ -36,6 +36,6 @@ public function test_new() public function data() { - return require __DIR__.'/fixture.php'; + return require __DIR__ . '/fixture.php'; } } diff --git a/tests/OptionTest.php b/tests/OptionTest.php index ecb67a8..e2756cd 100644 --- a/tests/OptionTest.php +++ b/tests/OptionTest.php @@ -51,7 +51,7 @@ public function test_filter() $this->assertSame(10, $o->filter('10')); $in = 'apple'; - $o = new Option('-f, --fruit', 'Age', 'orange', 'strtoupper'); + $o = new Option('-f, --fruit', 'Age', 'orange', 'strtoupper'); $this->assertNotSame($o->filter($in), $in); $this->assertSame('APPLE', $o->filter($in)); @@ -64,7 +64,7 @@ public function test_filter() public function data() { - $f = require __DIR__.'/fixture.php'; + $f = require __DIR__ . '/fixture.php'; return $f['options']; } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e91cc12..6c8c4f5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,3 +1,3 @@ Date: Thu, 28 Jun 2018 22:40:31 +0700 Subject: [PATCH 13/36] build: travis phpunit support for php5.6 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 005f527..19f9907 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,6 @@ "require": { }, "require-dev": { - "phpunit/phpunit": "^6.0.0" + "phpunit/phpunit": "^5.7 || ^6.0" } } From b9e194ef443d60e838136cc9dec5b5dfce46624f Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 29 Jun 2018 18:25:56 +0700 Subject: [PATCH 14/36] chore: drop php5 support :/ --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index db6e530..df2054a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: php php: - - 5.6 - 7.0 - 7.1 - 7.2 From bea3d9c7841f33528241542787ce83098ce23e30 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 29 Jun 2018 18:26:54 +0700 Subject: [PATCH 15/36] chore: drop php5 support :/ --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 19f9907..9a293e5 100644 --- a/composer.json +++ b/composer.json @@ -23,8 +23,9 @@ } }, "require": { + "php": ">=7.0" }, "require-dev": { - "phpunit/phpunit": "^5.7 || ^6.0" + "phpunit/phpunit": "^6.0" } } From 3bd2103871477d8e0646c241972584b4dbdab78c Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 29 Jun 2018 18:45:08 +0700 Subject: [PATCH 16/36] chore: add docblocks and typehints --- src/ArgvParser.php | 107 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 89 insertions(+), 18 deletions(-) diff --git a/src/ArgvParser.php b/src/ArgvParser.php index ee26a9a..c86ace7 100644 --- a/src/ArgvParser.php +++ b/src/ArgvParser.php @@ -12,27 +12,44 @@ */ class ArgvParser { + /** @var string */ protected $_version; + /** @var Option|null The last seen option */ protected $_lastOption; + /** @var string */ protected $_name; + /** @var string */ protected $_desc; + /** @var Option[] Registered options */ protected $_options = []; + /** @var array Parsed values indexed by option name */ protected $_values = []; + /** @var array Arguments that dont belong to any specific option */ protected $_args = []; + /** @var callable[] Events for options */ protected $_events = []; + /** @var bool Whether to allow unknown (not registered) options */ protected $_allowUnknown = false; + /** @var bool If the last seen option was variadic */ protected $_wasVariadic = false; - public function __construct($name, $desc = null, $allowUnknown = false) + /** + * Constructor. + * + * @param string $name + * @param string $desc + * @param bool $allowUnknown + */ + public function __construct(string $name, string $desc = null, bool $allowUnknown = false) { $this->_name = $name; $this->_desc = $desc; @@ -47,14 +64,31 @@ protected function addDefaultOptions() $this->option('-V, --version', 'Show version')->on([$this, 'showVersion']); } - public function version($version) + /** + * Sets version. + * + * @param string $version + * + * @return self + */ + public function version(string $version): self { $this->_version = $version; return $this; } - public function option($cmd, $desc = '', callable $filter = null, $default = null) + /** + * Registers new option. + * + * @param string $cmd [description] + * @param string $desc [description] + * @param callable|null $filter [description] + * @param mixed $default [description] + * + * @return self + */ + public function option(string $cmd, string $desc = '', callable $filter = null, $default = null): self { $option = new Option($cmd, $desc, $default, $filter); @@ -70,7 +104,14 @@ public function option($cmd, $desc = '', callable $filter = null, $default = nul return $this; } - public function on(callable $fn) + /** + * Sets event handler for last option. + * + * @param callable $fn [description] + * + * @return self + */ + public function on(callable $fn): self { \end($this->_options); @@ -79,7 +120,16 @@ public function on(callable $fn) return $this; } - public function parse(array $argv) + /** + * Parse the argv input. + * + * @param array $argv The first item is ignored. + * + * @return self [description] + * + * @throws \RuntimeException When argument is missing or invalid. + */ + public function parse(array $argv): self { \array_shift($argv); @@ -92,14 +142,14 @@ public function parse(array $argv) if ($arg[0] !== '-' || !empty($literal) || ($literal = $arg === '--')) { $this->parseArgs($arg); } else { - $i += (int) $this->parseOptions($arg, $nextArg, $i); + $i += (int) $this->parseOptions($arg, $nextArg); } } return $this->validate(); } - protected function parseArgs($arg) + protected function parseArgs(string $arg) { if ($this->_wasVariadic) { $this->_values[$this->_lastOption->attributeName()][] = $arg; @@ -108,7 +158,7 @@ protected function parseArgs($arg) } } - protected function parseOptions($arg, $nextArg, &$i) + protected function parseOptions(string $arg, string $nextArg = null) { $value = \substr($nextArg, 0, 1) === '-' ? null : $nextArg; $isValue = $value !== null; @@ -128,7 +178,7 @@ protected function parseOptions($arg, $nextArg, &$i) return $isValue; } - protected function handleUnknown($arg, $value) + protected function handleUnknown(string $arg, string $value = null) { if ($this->_allowUnknown) { $this->_values[$arg] = $value; @@ -147,7 +197,7 @@ protected function handleUnknown($arg, $value) return $this->showHelp(); } - protected function setValue(Option $option, $value) + protected function setValue(Option $option, string $value = null) { $name = $option->attributeName(); @@ -158,7 +208,7 @@ protected function setValue(Option $option, $value) $this->_values[$name] = $this->prepareValue($option, $value); } - protected function prepareValue(Option $option, $value) + protected function prepareValue(Option $option, string $value = null) { if ($option->bool()) { return !$option->default(); @@ -171,7 +221,7 @@ protected function prepareValue(Option $option, $value) return null === $value ? null : $option->filter($value); } - protected function validate() + protected function validate(): self { foreach ($this->_options as $option) { if (!$option->required()) { @@ -190,7 +240,14 @@ protected function validate() return $this; } - public function values($withDefaults = true) + /** + * Get values indexed by camelized attribute name. + * + * @param bool $withDefaults + * + * @return array + */ + public function values($withDefaults = true): array { $values = $this->_values; @@ -201,12 +258,26 @@ public function values($withDefaults = true) return $values; } + /** + * Get values. + * + * @param bool $withDefaults + * + * @return array + */ public function args() { return $this->_args; } - public function __get($key) + /** + * Magic getter for specific value by its key. + * + * @param string $key + * + * @return mixed + */ + public function __get(string $key) { return isset($this->_values[$key]) ? $this->_values[$key] : null; } @@ -225,7 +296,7 @@ protected function showVersion() exit(0); } - protected function normalize(array $args) + protected function normalize(array $args): array { $normalized = []; @@ -242,7 +313,7 @@ protected function normalize(array $args) return $normalized; } - protected function splitShort($arg) + protected function splitShort(string $arg): array { $args = \str_split(\substr($arg, 1)); @@ -251,7 +322,7 @@ protected function splitShort($arg) }, $args); } - protected function optionFor($arg) + protected function optionFor(string $arg) { if (isset($this->_options[$arg])) { return $this->_options[$arg]; @@ -264,7 +335,7 @@ protected function optionFor($arg) } } - protected function emit($event) + protected function emit(string $event) { if (empty($this->_events[$event])) { return; From 8ca8dcec87de3237c5832d62b773634b6af92666 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 29 Jun 2018 18:49:06 +0700 Subject: [PATCH 17/36] chore: add typehints --- src/Option.php | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/Option.php b/src/Option.php index c529ff4..b17081e 100644 --- a/src/Option.php +++ b/src/Option.php @@ -18,6 +18,8 @@ class Option protected $desc; + protected $rawCmd; + protected $default; protected $required = true; @@ -28,10 +30,9 @@ class Option protected $filter; - protected $collect = []; - - public function __construct($cmd, $desc = '', $default = null, callable $filter = null) + public function __construct(string $cmd, string $desc = null, $default = null, callable $filter = null) { + $this->rawCmd = $cmd; $this->desc = $desc; $this->default = $default; $this->filter = $filter; @@ -45,7 +46,7 @@ public function __construct($cmd, $desc = '', $default = null, callable $filter $this->parse($cmd); } - protected function parse($cmd) + protected function parse(string $cmd) { if (\strpos($cmd, '-with-') !== false) { $this->default = false; @@ -61,12 +62,12 @@ protected function parse($cmd) } } - public function long() + public function long(): string { return $this->long; } - public function short() + public function short(): string { return $this->short; } @@ -76,7 +77,7 @@ public function name() return \str_replace(['--', 'no-', 'with-'], '', $this->long); } - public function attributeName() + public function attributeName(): string { $words = \str_replace('-', ' ', $this->name()); @@ -85,27 +86,27 @@ public function attributeName() return \lcfirst($words); } - public function is($arg) + public function is($arg): bool { return $this->short === $arg || $this->long === $arg; } - public function required() + public function required(): bool { return $this->required; } - public function variadic() + public function variadic(): bool { return $this->variadic; } - public function default() + public function default(): bool { return $this->default; } - public function bool() + public function bool(): bool { return \preg_match('/\-no|\-with/', $this->long) > 0; } From f3c3b876e703ffc33bff80cf2c9d0583093ccb76 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 29 Jun 2018 19:02:02 +0700 Subject: [PATCH 18/36] refactor(color): add more colors, remove mute, add typehints, extend magic call support --- src/Color.php | 150 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 109 insertions(+), 41 deletions(-) diff --git a/src/Color.php b/src/Color.php index 2e3b706..712df6d 100644 --- a/src/Color.php +++ b/src/Color.php @@ -12,70 +12,86 @@ */ class Color { - const FG_RED = 31; - const FG_GREEN = 32; - const FG_YELLOW = 33; - const FG_BLUE = 36; - // @todo - + const BLACK = 30; + const RED = 31; + const GREEN = 32; + const YELLOW = 33; + const BLUE = 34; + const PURPLE = 35; + const CYAN = 36; + const WHITE = 37; + + /** @var string Cli format */ protected static $format = "\033[:bold:;:fg:;:bg:m:text:\033[0m"; + /** @var array Custom styles */ protected static $styles = []; - protected static $muted = false; - - public static function error($text, array $style = [], $eol = false) + public static function error(string $text, array $style = [], bool $eol = false) { - static::line($text, ['fg' => static::FG_RED] + $style, $eol); + return static::line($text, ['fg' => static::RED] + $style, $eol); } - public static function info($text, array $style = [], $eol = false) + public static function info(string $text, array $style = [], bool $eol = false) { - static::line($text, ['fg' => static::FG_BLUE] + $style, $eol); + return static::line($text, ['fg' => static::BLUE] + $style, $eol); } - public static function warn($text, array $style = [], $eol = false) + public static function warn(string $text, array $style = [], bool $eol = false) { - static::line($text, ['fg' => static::FG_YELLOW] + $style, $eol); + return static::line($text, ['fg' => static::YELLOW] + $style, $eol); } - public static function comment($text, array $style = [], $eol = false) + public static function comment(string $text, array $style = [], bool $eol = false) { - static::line($text, ['fg' => static::FG_RED] + $style, $eol); + return static::line($text, ['fg' => static::BLACK, 'bold' => 1] + $style, $eol); } - public static function line($text, array $style = [], $eol = false) + /** + * Returns a formatted/colored line. + * + * @param string $text + * @param array $style + * @param bool $eol End of line + * + * @return string + */ + public static function line(string $text, array $style = [], bool $eol = false) { - if (static::$muted) { - return; - } - - $style += ['bg' => null, 'fg' => 37, 'bold' => 0]; + $style += ['bg' => null, 'fg' => static::WHITE, 'bold' => false]; $format = $style['bg'] === null ? \str_replace(';:bg:', '', static::$format) : static::$format; - echo \strtr($format, [ + $line = \strtr($format, [ ':bold:' => (int) $style['bold'], ':fg:' => (int) $style['fg'], - ':bg:' => (int) $style['bg'], + ':bg:' => (int) $style['bg'] + 10, ':text:' => (string) $text, ]); if ($eol) { - static::eol(); + $line .= static::eol(); } + + return $line; } public static function eol() { - if (!static::$muted) { - echo PHP_EOL; - } + return PHP_EOL; } - public function style($name, array $style) + /** + * Register a custom style. + * + * @param string $name Example: 'alert' + * @param array $style Example: ['fg' => Color::RED, 'bg' => Color::YELLOW, 'bold' => true] + * + * @return void + */ + public function style(string $name, array $style) { $allow = ['fg' => true, 'bg' => true, 'bold' => true]; $style = \array_intersect_key($style, $allow); @@ -91,32 +107,84 @@ public function style($name, array $style) static::$styles[$name] = $style; } - public static function __callStatic($name, $arguments) + /** + * Magically build styles. + * + * @param string $name Example: 'boldError', 'bgGreenBold' etc + * @param array $arguments + * + * @return string + */ + public static function __callStatic(string $name, array $arguments) { - if (empty($arguments[0])) { + if (!isset($arguments[0])) { throw new \InvalidArgumentException('Text required'); } - list($text, $style, $eol) = $arguments + ['', [], false]; + list($name, $text, $style, $eol) = static::parseCall($name, $arguments); - if (\substr($name, 0, 4) === 'bold') { - $name = \lcfirst(\substr($name, 4)); - static::{$name}($text, ['bold' => true] + $style, $eol); + if (isset(static::$styles[$name])) { + return static::line($text, $style + static::$styles[$name], $eol); + } - return; + if (\defined($color = static::class . '::' . \strtoupper($name))) { + $name = 'line'; + $style += ['fg' => \constant($color)]; } - if (!isset(static::$styles[$name])) { + if (!\method_exists(static::class, $name)) { throw new \InvalidArgumentException(\sprintf('Style %s not defined', $name)); } - $style = static::$styles[$name]; + return static::{$name}($text, $style, $eol); + } + + /** + * Parse the name argument pairs to determine callable method and style params. + * + * @param string $name + * @param array $arguments + * + * @return array + */ + protected function parseCall(string $name, array $arguments) + { + list($text, $style, $eol) = $arguments + ['', [], false]; + + if (\stripos($name, 'bold') !== false) { + $name = \str_ireplace('bold', '', $name); + $style += ['bold' => 1]; + } + + if (!\preg_match_all('/([b|B|f|F]g)?([A-Z][a-z]+)([^A-Z])?/', $name, $matches)) { + return [\lcfirst($name) ?: 'line', $text, $style, $eol]; + } + + list($name, $style) = static::buildStyle($name, $style, $matches); - static::line($text, $style, $eol); + return [$name, $text, $style, $eol]; } - public static function mute($muted = true) + /** + * Build style parameter from matching combination. + * + * @param string $name + * @param array $style + * @param array $matches + * + * @return arrsy + */ + protected function buildStyle(string $name, array $style, array $matches) { - static::$muted = $muted; + foreach ($matches[0] as $i => $match) { + $name = \str_replace($match, '', $name); + $type = \strtolower($matches[1][$i]) ?: 'fg'; + + if (\defined($color = static::class . '::' . \strtoupper($matches[2][$i]))) { + $style += [$type => \constant($color)]; + } + } + + return [\lcfirst($name) ?: 'line', $style]; } } From 7a3619645349c916711d4c5327f48d6ca7dd3d56 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 29 Jun 2018 19:15:53 +0700 Subject: [PATCH 19/36] fix: option typehint, color eol supprot --- src/Color.php | 3 ++- src/Option.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Color.php b/src/Color.php index 712df6d..24e8cb5 100644 --- a/src/Color.php +++ b/src/Color.php @@ -71,7 +71,8 @@ public static function line(string $text, array $style = [], bool $eol = false) ':text:' => (string) $text, ]); - if ($eol) { + // Allow `Color::line('msg', [true])` instead of `Color::line('msg', [], true)` + if ($eol || true === $style[0] ?? null) { $line .= static::eol(); } diff --git a/src/Option.php b/src/Option.php index b17081e..3fadb42 100644 --- a/src/Option.php +++ b/src/Option.php @@ -101,7 +101,7 @@ public function variadic(): bool return $this->variadic; } - public function default(): bool + public function default() { return $this->default; } From d8a5909cbaed1b16aef74a555c4b1476b728c2c1 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 29 Jun 2018 19:16:24 +0700 Subject: [PATCH 20/36] feat: writer for cli --- src/Writer.php | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/Writer.php diff --git a/src/Writer.php b/src/Writer.php new file mode 100644 index 0000000..ef6c3ee --- /dev/null +++ b/src/Writer.php @@ -0,0 +1,50 @@ + + * @license MIT + * + * @link https://github.com/adhocore/cli + */ +class Writer +{ + /** @var string Write method to be relayed to Colorizer */ + protected $method; + + /** + * Magically set methods. + * + * @param string $name Like `red`, `bgRed`, 'bold', `error` etc + * + * @return self + */ + public function __get($name) + { + if (\strpos($this->method, $name) === false) { + $this->method .= $this->method ? \ucfirst($name) : $name; + } + + return $this; + } + + /** + * Write the formatted text to stdout or stderr. + * + * @param string $text + * @param bool $eol + * + * @return void + */ + public function write($text, $eol = false) + { + list($method, $this->method) = [$this->method ?: 'line', '']; + + $stream = \stripos($method, 'error') !== false ? \STDERR : \STDOUT; + + \fwrite($stream, Color::{$method}($text, [], $eol)); + } +} From f9c960b84a7c894821505007ed41548f42356e86 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 29 Jun 2018 21:27:05 +0700 Subject: [PATCH 21/36] refactor(color): make non static, fix eol shortcut, add ok() --- src/Color.php | 57 ++++++++++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/Color.php b/src/Color.php index 24e8cb5..e2b7b47 100644 --- a/src/Color.php +++ b/src/Color.php @@ -3,12 +3,12 @@ namespace Ahc\Cli; /** - * Cli Colorizer. + * Cli Colstatic ostatic rizer. * * @author Jitendra Adhikari * @license MIT * - * @link https://github.com/adhocore/cli + * @link static https://github.com/adhocore/cli */ class Color { @@ -22,29 +22,34 @@ class Color const WHITE = 37; /** @var string Cli format */ - protected static $format = "\033[:bold:;:fg:;:bg:m:text:\033[0m"; + protected $format = "\033[:bold:;:fg:;:bg:m:text:\033[0m"; - /** @var array Custom styles */ + /** @vstatic ar array Custom styles */ protected static $styles = []; - public static function error(string $text, array $style = [], bool $eol = false) + public function comment(string $text, array $style = [], bool $eol = false) { - return static::line($text, ['fg' => static::RED] + $style, $eol); + return $this->line($text, ['fg' => static::BLACK, 'bold' => 1] + $style, $eol); } - public static function info(string $text, array $style = [], bool $eol = false) + public function error(string $text, array $style = [], bool $eol = false) { - return static::line($text, ['fg' => static::BLUE] + $style, $eol); + return $this->line($text, ['fg' => static::RED] + $style, $eol); } - public static function warn(string $text, array $style = [], bool $eol = false) + public function ok(string $text, array $style = [], bool $eol = false) { - return static::line($text, ['fg' => static::YELLOW] + $style, $eol); + return $this->line($text, ['fg' => static::GREEN] + $style, $eol); } - public static function comment(string $text, array $style = [], bool $eol = false) + public function warn(string $text, array $style = [], bool $eol = false) { - return static::line($text, ['fg' => static::BLACK, 'bold' => 1] + $style, $eol); + return $this->line($text, ['fg' => static::YELLOW] + $style, $eol); + } + + public function info(string $text, array $style = [], bool $eol = false) + { + return $this->line($text, ['fg' => static::BLUE] + $style, $eol); } /** @@ -56,13 +61,13 @@ public static function comment(string $text, array $style = [], bool $eol = fals * * @return string */ - public static function line(string $text, array $style = [], bool $eol = false) + public function line(string $text, array $style = [], bool $eol = false) { $style += ['bg' => null, 'fg' => static::WHITE, 'bold' => false]; $format = $style['bg'] === null - ? \str_replace(';:bg:', '', static::$format) - : static::$format; + ? \str_replace(';:bg:', '', $this->format) + : $this->format; $line = \strtr($format, [ ':bold:' => (int) $style['bold'], @@ -72,14 +77,14 @@ public static function line(string $text, array $style = [], bool $eol = false) ]); // Allow `Color::line('msg', [true])` instead of `Color::line('msg', [], true)` - if ($eol || true === $style[0] ?? null) { - $line .= static::eol(); + if ($eol || !empty($style[0])) { + $line .= $this->eol(); } return $line; } - public static function eol() + public function eol() { return PHP_EOL; } @@ -92,7 +97,7 @@ public static function eol() * * @return void */ - public function style(string $name, array $style) + public static function style(string $name, array $style) { $allow = ['fg' => true, 'bg' => true, 'bold' => true]; $style = \array_intersect_key($style, $allow); @@ -116,16 +121,16 @@ public function style(string $name, array $style) * * @return string */ - public static function __callStatic(string $name, array $arguments) + public function __call(string $name, array $arguments) { if (!isset($arguments[0])) { throw new \InvalidArgumentException('Text required'); } - list($name, $text, $style, $eol) = static::parseCall($name, $arguments); + list($name, $text, $style, $eol) = $this->parseCall($name, $arguments); if (isset(static::$styles[$name])) { - return static::line($text, $style + static::$styles[$name], $eol); + return $this->line($text, $style + static::$styles[$name], $eol); } if (\defined($color = static::class . '::' . \strtoupper($name))) { @@ -133,11 +138,11 @@ public static function __callStatic(string $name, array $arguments) $style += ['fg' => \constant($color)]; } - if (!\method_exists(static::class, $name)) { - throw new \InvalidArgumentException(\sprintf('Style %s not defined', $name)); + if (!\method_exists($this, $name)) { + throw new \InvalidArgumentException(\sprintf('Style "%s" not defined', $name)); } - return static::{$name}($text, $style, $eol); + return $this->{$name}($text, $style, $eol); } /** @@ -161,7 +166,7 @@ protected function parseCall(string $name, array $arguments) return [\lcfirst($name) ?: 'line', $text, $style, $eol]; } - list($name, $style) = static::buildStyle($name, $style, $matches); + list($name, $style) = $this->buildStyle($name, $style, $matches); return [$name, $text, $style, $eol]; } From 89b67e7c590b281e4f338a4f74d8605cf3531563 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 29 Jun 2018 21:28:37 +0700 Subject: [PATCH 22/36] refactor(writer): colorizer is no longer static, add typehints --- src/Writer.php | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Writer.php b/src/Writer.php index ef6c3ee..d1ceebc 100644 --- a/src/Writer.php +++ b/src/Writer.php @@ -15,6 +15,14 @@ class Writer /** @var string Write method to be relayed to Colorizer */ protected $method; + /** @var Color */ + protected $colorizer; + + public function __construct() + { + $this->colorizer = new Color; + } + /** * Magically set methods. * @@ -22,7 +30,7 @@ class Writer * * @return self */ - public function __get($name) + public function __get(string $name): self { if (\strpos($this->method, $name) === false) { $this->method .= $this->method ? \ucfirst($name) : $name; @@ -39,12 +47,12 @@ public function __get($name) * * @return void */ - public function write($text, $eol = false) + public function write(string $text, bool $eol = false) { list($method, $this->method) = [$this->method ?: 'line', '']; $stream = \stripos($method, 'error') !== false ? \STDERR : \STDOUT; - \fwrite($stream, Color::{$method}($text, [], $eol)); + \fwrite($stream, $this->colorizer->{$method}($text, [], $eol)); } } From 70b2a53f2fce2ae1072f42e096bb44bb1bd2f065 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 29 Jun 2018 21:29:07 +0700 Subject: [PATCH 23/36] test: color --- tests/ColorTest.php | 73 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/ColorTest.php diff --git a/tests/ColorTest.php b/tests/ColorTest.php new file mode 100644 index 0000000..d9646ee --- /dev/null +++ b/tests/ColorTest.php @@ -0,0 +1,73 @@ +assertSame("\033[0;{$color}m{$method}\033[0m", (new Color)->{$method}($method)); + } + + public function test_comment() + { + $this->assertSame("\033[1;30mcomment\033[0m", (new Color)->comment('comment')); + } + + public function test_custom_style() + { + Color::style('alert', ['bg' => Color::YELLOW, 'fg' => Color::RED, 'bold' => 1]); + + $this->assertSame("\033[1;31;43malert\033[0m", (new Color)->alert('alert')); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Trying to define existing style'); + + Color::style('alert', ['bg' => Color::BLACK]); + } + + public function test_invalid_custom_style() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Trying to set empty or invalid style'); + + Color::style('alert', ['invalid' => true]); + } + + public function test_magic_call() + { + $this->assertSame("\033[1;37mline\033[0m", (new Color)->bold('line')); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Text required'); + + (new Color)->bgRed(); + } + + public function test_magic_call_color() + { + $this->assertSame("\033[0;35mpurple\033[0m", (new Color)->purple('purple')); + } + + public function test_magic_call_invalid() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Style "random" not defined'); + + (new Color)->random('Rand'); + } + + public function methods() + { + return [ + ['error', 31], + ['ok', 32], + ['warn', 33], + ['info', 34], + ]; + } +} From b4c2777ff1510c1e772a61efd54a99fe60cd3e05 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 29 Jun 2018 21:29:25 +0700 Subject: [PATCH 24/36] test: writer --- tests/WriterTest.php | 68 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/WriterTest.php diff --git a/tests/WriterTest.php b/tests/WriterTest.php new file mode 100644 index 0000000..9116b10 --- /dev/null +++ b/tests/WriterTest.php @@ -0,0 +1,68 @@ +write('Hey'); + + $this->assertContains('Hey', StreamInterceptor::$buffer); + $this->assertSame("\033[0;37mHey\033[0m", StreamInterceptor::$buffer); + } + + public function test_write_error() + { + (new Writer)->error->write('Something wrong'); + + $this->assertContains('Something wrong', StreamInterceptor::$buffer); + $this->assertSame("\033[0;31mSomething wrong\033[0m", StreamInterceptor::$buffer); + } + + public function test_write_with_newline() + { + (new Writer)->write('Hello', true); + + $this->assertContains('Hello', StreamInterceptor::$buffer); + $this->assertSame("\033[0;37mHello\033[0m" . PHP_EOL, StreamInterceptor::$buffer); + } + + public function test_write_bold_red_bggreen() + { + (new Writer)->bold->red->bgGreen->write('bold->red->bgGreen'); + + $this->assertContains('bold->red->bgGreen', StreamInterceptor::$buffer); + $this->assertSame("\033[1;31;42mbold->red->bgGreen\033[0m", StreamInterceptor::$buffer); + } +} + +class StreamInterceptor extends \php_user_filter +{ + public static $buffer = ''; + + public function filter($in, $out, &$consumed, $closing) + { + while ($bucket = stream_bucket_make_writeable($in)) { + static::$buffer .= $bucket->data; + } + + return PSFS_PASS_ON; + } +} From d126041016c117e701f1ee1336f1778aefccfa9f Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 29 Jun 2018 21:31:44 +0700 Subject: [PATCH 25/36] chore --- src/ArgvParser.php | 14 +++++++------- src/Color.php | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/ArgvParser.php b/src/ArgvParser.php index c86ace7..433e9fc 100644 --- a/src/ArgvParser.php +++ b/src/ArgvParser.php @@ -81,10 +81,10 @@ public function version(string $version): self /** * Registers new option. * - * @param string $cmd [description] - * @param string $desc [description] - * @param callable|null $filter [description] - * @param mixed $default [description] + * @param string $cmd JWTException + * @param string $desc JWTException + * @param callable|null $filter JWTException + * @param mixed $default JWTException * * @return self */ @@ -107,7 +107,7 @@ public function option(string $cmd, string $desc = '', callable $filter = null, /** * Sets event handler for last option. * - * @param callable $fn [description] + * @param callable $fn JWTException * * @return self */ @@ -125,9 +125,9 @@ public function on(callable $fn): self * * @param array $argv The first item is ignored. * - * @return self [description] - * * @throws \RuntimeException When argument is missing or invalid. + * + * @return self */ public function parse(array $argv): self { diff --git a/src/Color.php b/src/Color.php index e2b7b47..f73d3c4 100644 --- a/src/Color.php +++ b/src/Color.php @@ -55,9 +55,9 @@ public function info(string $text, array $style = [], bool $eol = false) /** * Returns a formatted/colored line. * - * @param string $text - * @param array $style - * @param bool $eol End of line + * @param string $text + * @param array $style + * @param bool $eol End of line * * @return string */ @@ -116,8 +116,8 @@ public static function style(string $name, array $style) /** * Magically build styles. * - * @param string $name Example: 'boldError', 'bgGreenBold' etc - * @param array $arguments + * @param string $name Example: 'boldError', 'bgGreenBold' etc + * @param array $arguments * * @return string */ @@ -148,8 +148,8 @@ public function __call(string $name, array $arguments) /** * Parse the name argument pairs to determine callable method and style params. * - * @param string $name - * @param array $arguments + * @param string $name + * @param array $arguments * * @return array */ From 74caaabc05f964e9c1c99e17e5aeca785f33883a Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Sat, 30 Jun 2018 13:50:56 +0700 Subject: [PATCH 26/36] chore: composer keyword --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 9a293e5..1c818e9 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,8 @@ "description": "Command line interface library for PHP", "type": "library", "keywords": [ - "php", "command", "argv-parser", "cli", "cli-color", "cli-action" + "php", "command", "argv-parser", "cli", "cli-color", "cli-action", "console", + "cli-writer", "argument-parser", "cli-option" ], "license": "MIT", "authors": [ From 08617fb1bd68c6496b18951e477befda43e0f252 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Sat, 30 Jun 2018 13:54:33 +0700 Subject: [PATCH 27/36] feat: add Argument class --- src/Argument.php | 75 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/Argument.php diff --git a/src/Argument.php b/src/Argument.php new file mode 100644 index 0000000..e236bb8 --- /dev/null +++ b/src/Argument.php @@ -0,0 +1,75 @@ + + * @license MIT + * + * @link https://github.com/adhocore/cli + */ +class Argument +{ + use InflectsString; + + protected $name; + + protected $rawArg; + + protected $default; + + protected $required = false; + + protected $variadic = false; + + public function __construct(string $arg) + { + $this->rawArg = $arg; + + $this->parse($arg); + } + + protected function parse(string $arg) + { + $this->required = $arg[0] === '<'; + $this->variadic = \strpos($arg, '...') !== false; + $this->name = $name = \str_replace(['<', '>', '[', ']', '.'], '', $arg); + + // Format is "name:default+value1,default+value2" ('+'' => ' ')! + if (\strpos($name, ':') !== false) { + $name = \str_replace('+', ' ', $name); + list($this->name, $this->default) = \explode(':', $name, 2); + } + } + + public function name(): string + { + return $this->name; + } + + public function attributeName(): string + { + return $this->toCamelCase($this->name); + } + + public function required(): bool + { + return $this->required; + } + + public function variadic(): bool + { + return $this->variadic; + } + + public function default() + { + if (!$this->variadic) { + return $this->default; + } + + return null === $this->default ? [] : \explode(',', $this->default); + } +} From 245e20026ea8ea89f8323e3f1717473e162376f7 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Sat, 30 Jun 2018 13:55:00 +0700 Subject: [PATCH 28/36] feat: add str inflecton helper --- src/InflectsString.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/InflectsString.php diff --git a/src/InflectsString.php b/src/InflectsString.php new file mode 100644 index 0000000..2a71ff1 --- /dev/null +++ b/src/InflectsString.php @@ -0,0 +1,23 @@ + + * @license MIT + * + * @link https://github.com/adhocore/cli + */ +trait InflectsString +{ + public function toCamelCase(string $string) + { + $words = \str_replace('-', ' ', $string); + + $words = \str_replace(' ', '', \ucwords($words)); + + return \lcfirst($words); + } +} From dddaa5046f1d9f50f5dd6cd7ea5c03bb336a28f2 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Sat, 30 Jun 2018 13:56:55 +0700 Subject: [PATCH 29/36] feat: add abstract base parser --- src/Parser.php | 201 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 src/Parser.php diff --git a/src/Parser.php b/src/Parser.php new file mode 100644 index 0000000..1dab6e5 --- /dev/null +++ b/src/Parser.php @@ -0,0 +1,201 @@ + + * @license MIT + * + * @link https://github.com/adhocore/cli + */ +abstract class Parser +{ + /** @var Option|null The last seen option */ + protected $_lastOption; + + /** @var bool If the last seen option was variadic */ + protected $_wasVariadic = false; + + /** @var Option[] Registered options */ + protected $_options = []; + + /** @var Option[] Registered arguments */ + protected $_arguments = []; + + /** @var array Parsed values indexed by option name */ + protected $_values = []; + + /** + * Parse the argv input. + * + * @param array $argv The first item is ignored. + * + * @throws \RuntimeException When argument is missing or invalid. + * + * @return self + */ + public function parse(array $argv): self + { + \array_shift($argv); + + $argv = $this->normalize($argv); + $count = \count($argv); + + for ($i = 0; $i < $count; $i++) { + list($arg, $nextArg) = [$argv[$i], isset($argv[$i + 1]) ? $argv[$i + 1] : null]; + + if ($arg[0] !== '-' || !empty($literal) || ($literal = $arg === '--')) { + $this->parseArgs($arg); + } else { + $i += (int) $this->parseOptions($arg, $nextArg); + } + } + + $this->validate(); + + return $this; + } + + protected function normalize(array $args): array + { + $normalized = []; + + foreach ($args as $arg) { + if (\preg_match('/^\-\w{2,}/', $arg)) { + $normalized = \array_merge($normalized, $this->splitShort($arg)); + } elseif (\preg_match('/^\-\-\w{2,}\=/', $arg)) { + $normalized = \array_merge($normalized, explode('=', $arg)); + } else { + $normalized[] = $arg; + } + } + + return $normalized; + } + + protected function splitShort(string $arg): array + { + $args = \str_split(\substr($arg, 1)); + + return \array_map(function ($a) { + return "-$a"; + }, $args); + } + + protected function parseArgs(string $arg) + { + if ($arg == '--') { + return; + } + + if ($this->_wasVariadic) { + $this->_values[$this->_lastOption->attributeName()][] = $arg; + + return; + } + + if (!$argument = \reset($this->_arguments)) { + $this->_values[] = $arg; + + return; + } + + $name = $argument->attributeName(); + if ($argument->variadic()) { + $this->_values[$name][] = $arg; + + return; + } + + $this->_values[$name] = $arg; + + // Otherwise we will always collect same arguments again! + \array_shift($this->_arguments); + } + + protected function parseOptions(string $arg, string $nextArg = null) + { + $value = \substr($nextArg, 0, 1) === '-' ? null : $nextArg; + $isValue = $value !== null; + + $this->_lastOption = $option = $this->optionFor($arg); + $this->_wasVariadic = $option ? $option->variadic() : false; + + if (!$option) { + $this->handleUnknown($arg, $value); + + return $isValue; + } + + $this->emit($option->long()); + $this->setValue($option, $value); + + return $isValue; + } + + protected function optionFor(string $arg) + { + if (isset($this->_options[$arg])) { + return $this->_options[$arg]; + } + + foreach ($this->_options as $option) { + if ($option->is($arg)) { + return $option; + } + } + } + + abstract protected function handleUnknown(string $arg, string $value = null); + abstract protected function emit(string $event); + + protected function setValue(Option $option, string $value = null) + { + $name = $option->attributeName(); + + if (null === $value = $this->prepareValue($option, $value)) { + return; + } + + $this->_values[$name] = $value; + } + + protected function prepareValue(Option $option, string $value = null) + { + if ($option->bool()) { + return !$option->default(); + } + + if ($option->variadic()) { + return (array) $value; + } + + if (null === $value && $option->optional()) { + $value = true; + } + + return null === $value ? null : $option->filter($value); + } + + protected function validate() + { + foreach ($this->_options + $this->_arguments as $option) { + if (!$option->required()) { + continue; + } + + $value = $this->_values[$option->attributeName()]; + $isArg = $option instanceof Argument; + + if (null === $value || [] === $value) { + throw new \RuntimeException(\sprintf( + '%s "%s" is required', + $isArg ? 'Argument' : 'Option', + $isArg ? $option->name() : $option->long() + )); + } + } + } +} From 89a42c9deb00ea234cd65e9bb0c379c67ca59ed0 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Sat, 30 Jun 2018 13:58:03 +0700 Subject: [PATCH 30/36] refactor: attributeName, add typehints --- src/Option.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Option.php b/src/Option.php index 3fadb42..590bf3f 100644 --- a/src/Option.php +++ b/src/Option.php @@ -12,6 +12,8 @@ */ class Option { + use InflectsString; + protected $short; protected $long; @@ -72,18 +74,14 @@ public function short(): string return $this->short; } - public function name() + public function name(): string { return \str_replace(['--', 'no-', 'with-'], '', $this->long); } public function attributeName(): string { - $words = \str_replace('-', ' ', $this->name()); - - $words = \str_replace(' ', '', \ucwords($words)); - - return \lcfirst($words); + return $this->toCamelCase($this->name()); } public function is($arg): bool @@ -96,6 +94,11 @@ public function required(): bool return $this->required; } + public function optional(): bool + { + return $this->optional; + } + public function variadic(): bool { return $this->variadic; From df075cd5f355bed00383401a8d9cd2359a9110fb Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Sat, 30 Jun 2018 14:00:37 +0700 Subject: [PATCH 31/36] refactor: add arguments(), extract parsing to base class, cleanup --- src/ArgvParser.php | 245 +++++++++++---------------------------------- 1 file changed, 58 insertions(+), 187 deletions(-) diff --git a/src/ArgvParser.php b/src/ArgvParser.php index 433e9fc..36b702b 100644 --- a/src/ArgvParser.php +++ b/src/ArgvParser.php @@ -10,38 +10,25 @@ * * @link https://github.com/adhocore/cli */ -class ArgvParser +class ArgvParser extends Parser { + use InflectsString; + /** @var string */ protected $_version; - /** @var Option|null The last seen option */ - protected $_lastOption; - /** @var string */ protected $_name; /** @var string */ protected $_desc; - /** @var Option[] Registered options */ - protected $_options = []; - - /** @var array Parsed values indexed by option name */ - protected $_values = []; - - /** @var array Arguments that dont belong to any specific option */ - protected $_args = []; - /** @var callable[] Events for options */ protected $_events = []; /** @var bool Whether to allow unknown (not registered) options */ protected $_allowUnknown = false; - /** @var bool If the last seen option was variadic */ - protected $_wasVariadic = false; - /** * Constructor. * @@ -55,11 +42,6 @@ public function __construct(string $name, string $desc = null, bool $allowUnknow $this->_desc = $desc; $this->_allowUnknown = $allowUnknown; - $this->addDefaultOptions(); - } - - protected function addDefaultOptions() - { $this->option('-h, --help', 'Show help')->on([$this, 'showHelp']); $this->option('-V, --version', 'Show version')->on([$this, 'showVersion']); } @@ -78,13 +60,37 @@ public function version(string $version): self return $this; } + /** + * Registers argument definitions (all at once). Only last one can be variadic. + * + * @param string $definitions + * + * @return self + */ + 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'); + } + + $this->_arguments[$argument->name()] = $argument; + $this->_values[$argument->attributeName()] = $argument->default(); + } + + return $this; + } + /** * Registers new option. * - * @param string $cmd JWTException - * @param string $desc JWTException - * @param callable|null $filter JWTException - * @param mixed $default JWTException + * @param string $cmd + * @param string $desc + * @param callable|null $filter + * @param mixed $default * * @return self */ @@ -94,7 +100,7 @@ public function option(string $cmd, string $desc = '', callable $filter = null, if (isset($this->_options[$option->long()])) { throw new \InvalidArgumentException( - \sprintf('The option %s is already registered', $option->long()) + \sprintf('The option "%s" is already registered', $option->long()) ); } @@ -107,7 +113,7 @@ public function option(string $cmd, string $desc = '', callable $filter = null, /** * Sets event handler for last option. * - * @param callable $fn JWTException + * @param callable $fn * * @return self */ @@ -120,76 +126,22 @@ public function on(callable $fn): self return $this; } - /** - * Parse the argv input. - * - * @param array $argv The first item is ignored. - * - * @throws \RuntimeException When argument is missing or invalid. - * - * @return self - */ - public function parse(array $argv): self - { - \array_shift($argv); - - $argv = $this->normalize($argv); - $count = \count($argv); - - for ($i = 0; $i < $count; $i++) { - list($arg, $nextArg) = [$argv[$i], isset($argv[$i + 1]) ? $argv[$i + 1] : null]; - - if ($arg[0] !== '-' || !empty($literal) || ($literal = $arg === '--')) { - $this->parseArgs($arg); - } else { - $i += (int) $this->parseOptions($arg, $nextArg); - } - } - - return $this->validate(); - } - - protected function parseArgs(string $arg) - { - if ($this->_wasVariadic) { - $this->_values[$this->_lastOption->attributeName()][] = $arg; - } else { - $this->_args[] = $arg; - } - } - - protected function parseOptions(string $arg, string $nextArg = null) - { - $value = \substr($nextArg, 0, 1) === '-' ? null : $nextArg; - $isValue = $value !== null; - - $this->_lastOption = $option = $this->optionFor($arg); - $this->_wasVariadic = $option ? $option->variadic() : false; - - if (!$option) { - $this->handleUnknown($arg, $value); - - return $isValue; - } - - $this->emit($option->long()); - $this->setValue($option, $value); - - return $isValue; - } - protected function handleUnknown(string $arg, string $value = null) { if ($this->_allowUnknown) { - $this->_values[$arg] = $value; + $this->_values[$this->toCamelCase($arg)] = $value; return; } + $values = \array_filter($this->_values, function ($value) { + return $value !== null; + }); + // Has some value, error! - if ($this->_values) { + if ($values) { throw new \RuntimeException( - \sprintf('Option %s not registered', $arg) + \sprintf('Option "%s" not registered', $arg) ); } @@ -197,49 +149,6 @@ protected function handleUnknown(string $arg, string $value = null) return $this->showHelp(); } - protected function setValue(Option $option, string $value = null) - { - $name = $option->attributeName(); - - if (null === $value && null !== $this->_values[$name]) { - return; - } - - $this->_values[$name] = $this->prepareValue($option, $value); - } - - protected function prepareValue(Option $option, string $value = null) - { - if ($option->bool()) { - return !$option->default(); - } - - if ($option->variadic()) { - return (array) $value; - } - - return null === $value ? null : $option->filter($value); - } - - protected function validate(): self - { - foreach ($this->_options as $option) { - if (!$option->required()) { - continue; - } - - $value = $this->_values[$option->attributeName()]; - - if (null === $value || [] === $value) { - throw new \RuntimeException( - \sprintf('Option %s|%s is required', $option->short(), $option->long()) - ); - } - } - - return $this; - } - /** * Get values indexed by camelized attribute name. * @@ -247,7 +156,7 @@ protected function validate(): self * * @return array */ - public function values($withDefaults = true): array + public function values(bool $withDefaults = true): array { $values = $this->_values; @@ -258,18 +167,6 @@ public function values($withDefaults = true): array return $values; } - /** - * Get values. - * - * @param bool $withDefaults - * - * @return array - */ - public function args() - { - return $this->_args; - } - /** * Magic getter for specific value by its key. * @@ -284,55 +181,19 @@ public function __get(string $key) protected function showHelp() { - echo "{$this->_name}, version {$this->_version}\n"; - - exit('help'); - } - - protected function showVersion() - { - echo "{$this->_name}, version {$this->_version}\n"; - - exit(0); - } + echo "{$this->_name}, version {$this->_version}" . PHP_EOL; - protected function normalize(array $args): array - { - $normalized = []; - - foreach ($args as $arg) { - if (\preg_match('/^\-\w{2,}/', $arg)) { - $normalized = \array_merge($normalized, $this->splitShort($arg)); - } elseif (\preg_match('/^\-\-\w{2,}\=/', $arg)) { - $normalized = \array_merge($normalized, explode('=', $arg)); - } else { - $normalized[] = $arg; - } - } + // @todo: build help msg! + echo "help\n"; - return $normalized; + _exit(); } - protected function splitShort(string $arg): array + protected function showVersion() { - $args = \str_split(\substr($arg, 1)); + echo $this->_version . PHP_EOL; - return \array_map(function ($a) { - return "-$a"; - }, $args); - } - - protected function optionFor(string $arg) - { - if (isset($this->_options[$arg])) { - return $this->_options[$arg]; - } - - foreach ($this->_options as $option) { - if ($option->is($arg)) { - return $option; - } - } + _exit(); } protected function emit(string $event) @@ -346,3 +207,13 @@ protected function emit(string $event) $callback(); } } + +// @codeCoverageIgnoreStart +if (!\function_exists(__NAMESPACE__ . '\\_exit')) +{ + function _exit($code = 0) + { + exit($code); + } +} +// @codeCoverageIgnoreEnd From 8e364e81c58170c5812d48e3c3e71cfb24e34c5b Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Sat, 30 Jun 2018 14:01:09 +0700 Subject: [PATCH 32/36] test: mock _exit --- tests/bootstrap.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6c8c4f5..385ce9c 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,3 +1,10 @@ Date: Sat, 30 Jun 2018 14:01:57 +0700 Subject: [PATCH 33/36] test: fix fixture exception msg --- tests/fixture.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/fixture.php b/tests/fixture.php index a3f936f..a8ac401 100644 --- a/tests/fixture.php +++ b/tests/fixture.php @@ -77,27 +77,27 @@ 'argvs' => [ [ 'argv' => [], - 'throws' => [\RuntimeException::class, 'Option -v|--virtual is required'], + 'throws' => [\RuntimeException::class, 'Option "--virtual" is required'], ], [ 'argv' => [''], - 'throws' => [\RuntimeException::class, 'Option -v|--virtual is required'], + 'throws' => [\RuntimeException::class, 'Option "--virtual" is required'], ], [ 'argv' => ['-x', 1], - 'throws' => [\RuntimeException::class, 'Option -v|--virtual is required'], + 'throws' => [\RuntimeException::class, 'Option "--virtual" is required'], ], [ 'argv' => ['-x', 1], - 'throws' => [\RuntimeException::class, 'Option -v|--virtual is required'], + 'throws' => [\RuntimeException::class, 'Option "--virtual" is required'], ], [ 'argv' => ['-v'], - 'throws' => [\RuntimeException::class, 'Option -v|--virtual is required'], + 'throws' => [\RuntimeException::class, 'Option "--virtual" is required'], ], [ 'argv' => ['--virtual'], - 'throws' => [\RuntimeException::class, 'Option -v|--virtual is required'], + 'throws' => [\RuntimeException::class, 'Option "--virtual" is required'], ], ], ]; From 1aa427e8e09298cc25574a9634da5d409414e4ae Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Sat, 30 Jun 2018 14:02:26 +0700 Subject: [PATCH 34/36] test: cover all --- tests/ArgvParserTest.php | 171 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/tests/ArgvParserTest.php b/tests/ArgvParserTest.php index 0bee513..5a16027 100644 --- a/tests/ArgvParserTest.php +++ b/tests/ArgvParserTest.php @@ -38,4 +38,175 @@ public function data() { return require __DIR__ . '/fixture.php'; } + + public function test_arguments() + { + $p = $this->newParser()->arguments(' [env]')->parse(['php', 'mycmd']); + + $this->assertSame('mycmd', $p->cmd); + $this->assertNull($p->env, 'No default'); + + $p = $this->newParser()->arguments(' [hobbies...]')->parse(['php']); + + $this->assertSame('adhocore', $p->id, 'Default'); + $this->assertEmpty($p->hobbies, 'No default'); + $this->assertSame([], $p->hobbies, 'Variadic'); + + $p = $this->newParser()->arguments(' [dirs...]')->parse(['php', 'dir1', 'dir2', 'dir3']); + $this->assertSame('dir1', $p->dir); + $this->assertTrue(is_array($p->dirs)); + $this->assertSame(['dir2', 'dir3'], $p->dirs); + } + + public function test_arguments_variadic_not_last() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Only last argument can be variadic'); + + $p = $this->newParser()->arguments(' [env]'); + } + + public function test_arguments_with_options() + { + $p = $this->newParser()->arguments(' [env]') + ->option('-c --config', 'Config') + ->option('-d --dir', 'Dir') + ->parse(['php', 'thecmd', '-d', 'dir1', 'dev', '-c', 'conf.yml', 'any', 'thing']); + + $this->assertArrayHasKey('help', $p->values()); + $this->assertArrayNotHasKey('help', $p->values(false)); + + $this->assertSame('dir1', $p->dir); + $this->assertSame('conf.yml', $p->config); + $this->assertSame('thecmd', $p->cmd); + $this->assertSame('dev', $p->env); + $this->assertSame('any', $p->{0}); + $this->assertSame('thing', $p->{1}); + } + + public function test_options_repeat() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The option "--apple" is already registered'); + + $p = $this->newParser()->option('-a --apple', 'Apple')->option('-a --apple', 'Apple'); + } + + public function test_options_unknown() + { + $p = $this->newParser('', '', true)->parse(['php', '--hot-path', '/path']); + $this->assertSame('/path', $p->hotPath, 'Allow unknown'); + + ob_start(); + $p = $this->newParser()->parse(['php', '--unknown', '1']); + $this->assertContains('help', ob_get_clean(), 'Show help'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Option "--random" not registered'); + + // Dont allow unknown + $p = $this->newParser()->option('-k known [opt]')->parse(['php', '-k', '--random', 'rr']); + } + + public function test_literals() + { + $p = $this->newParser()->option('-a --apple', 'Apple')->option('-b --ball', 'Ball'); + + $p->parse(['php', '-a', 'the apple', '--', '--ball', 'the ball']); + + $this->assertSame('the apple', $p->apple); + $this->assertNotSame('the ball', $p->ball); + $this->assertSame('--ball', $p->{0}, 'Should be arg'); + $this->assertSame('the ball', $p->{1}, 'Should be arg'); + } + + public function test_options() + { + $p = $this->newParser()->option('-u --user-id [id]', 'User id')->parse(['php']); + $this->assertNull($p->userId, 'Optional no default'); + + $p = $this->newParser()->option('-c --cheese [type]', 'User id')->parse(['php', '-c']); + $this->assertSame(true, $p->cheese, 'Optional given'); + + $p = $this->newParser()->option('-u --user-id [id]', 'User id', null, 1)->parse(['php']); + $this->assertSame(1, $p->userId, 'Optional default'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Option "--user-id" is required'); + + $p = $this->newParser()->option('-u --user-id ', 'User id')->parse(['php']); + } + + public function test_special_options() + { + $p = $this->newParser()->option('-n --no-more', '')->option('-w --with-that', ''); + + $p->parse(['php', '-nw']); + + $this->assertTrue($p->that, '--with becomes true when given'); + $this->assertFalse($p->more, '--no becomes false when given'); + + $p = $this->newParser()->option('--any')->parse(['php', '--any=thing']); + $this->assertSame('thing', $p->any); + + $p = $this->newParser()->option('-m --many [item...]')->parse(['php', '--many=1', '2']); + $this->assertSame(['1', '2'], $p->many); + } + + public function test_bool_options() + { + $p = $this->newParser()->option('-n --no-more', '')->option('-w --with-that', '') + ->parse(['php']); + + $this->assertTrue($p->more); + $this->assertFalse($p->that); + + $p = $this->newParser()->option('-n --no-more', '')->option('-w --with-that', '') + ->parse(['php', '--no-more', '-w']); + + $this->assertFalse($p->more); + $this->assertTrue($p->that); + } + + public function test_event() + { + $p = $this->newParser()->option('--hello')->on(function () { + echo 'hello event'; + }); + + ob_start(); + $p->parse(['php', '--hello']); + + $this->assertSame('hello event', ob_get_clean()); + } + + public function test_default_options() + { + ob_start(); + $p = $this->newParser('v1.0.1')->parse(['php', '--version']); + $this->assertContains('v1.0.1', ob_get_clean(), 'Long'); + + ob_start(); + $p = $this->newParser('v2.0.1')->parse(['php', '-V']); + $this->assertContains('v2.0.1', ob_get_clean(), 'Short'); + + ob_start(); + $p = $this->newParser()->parse(['php', '--help']); + $this->assertContains('ArgvParserTest', $buffer = ob_get_clean()); + $this->assertContains('help', $buffer); + } + + public function test_no_value() + { + $p = $this->newParser()->option('-x --xyz')->parse(['php', '-x']); + + $this->assertNull($p->xyz); + } + + protected function newParser(string $version = '0.0.1', string $desc = null, bool $allowUnknown = false) + { + $p = new ArgvParser('ArgvParserTest', $desc, $allowUnknown); + + return $p->version($version); + } } From 43893a57987a92bd54f8d342529aef4281eb87ec Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Sat, 30 Jun 2018 14:04:33 +0700 Subject: [PATCH 35/36] style: lint --- src/Argument.php | 2 +- src/ArgvParser.php | 3 +-- src/Parser.php | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Argument.php b/src/Argument.php index e236bb8..910cb1a 100644 --- a/src/Argument.php +++ b/src/Argument.php @@ -39,7 +39,7 @@ protected function parse(string $arg) // Format is "name:default+value1,default+value2" ('+'' => ' ')! if (\strpos($name, ':') !== false) { - $name = \str_replace('+', ' ', $name); + $name = \str_replace('+', ' ', $name); list($this->name, $this->default) = \explode(':', $name, 2); } } diff --git a/src/ArgvParser.php b/src/ArgvParser.php index 36b702b..8a87b5c 100644 --- a/src/ArgvParser.php +++ b/src/ArgvParser.php @@ -209,8 +209,7 @@ protected function emit(string $event) } // @codeCoverageIgnoreStart -if (!\function_exists(__NAMESPACE__ . '\\_exit')) -{ +if (!\function_exists(__NAMESPACE__ . '\\_exit')) { function _exit($code = 0) { exit($code); diff --git a/src/Parser.php b/src/Parser.php index 1dab6e5..d61804d 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -149,6 +149,7 @@ protected function optionFor(string $arg) } abstract protected function handleUnknown(string $arg, string $value = null); + abstract protected function emit(string $event); protected function setValue(Option $option, string $value = null) From 836535c7be0cb94550684a3ad232606af7a7e8f6 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Sat, 30 Jun 2018 15:24:22 +0700 Subject: [PATCH 36/36] refactor(parser): improve validate() --- src/Parser.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Parser.php b/src/Parser.php index d61804d..7d2e459 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -182,20 +182,20 @@ protected function prepareValue(Option $option, string $value = null) protected function validate() { - foreach ($this->_options + $this->_arguments as $option) { - if (!$option->required()) { + foreach ($this->_options + $this->_arguments as $item) { + if (!$item->required()) { continue; } - $value = $this->_values[$option->attributeName()]; - $isArg = $option instanceof Argument; + list($name, $label) = [$item->name(), 'Argument']; + if ($item instanceof Option) { + list($name, $label) = [$item->long(), 'Option']; + } - if (null === $value || [] === $value) { - throw new \RuntimeException(\sprintf( - '%s "%s" is required', - $isArg ? 'Argument' : 'Option', - $isArg ? $option->name() : $option->long() - )); + if (\in_array($this->_values[$item->attributeName()], [null, []])) { + throw new \RuntimeException( + \sprintf('%s "%s" is required', $label, $name) + ); } } }