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..df2054a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: php + +php: + - 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) 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 +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1c818e9 --- /dev/null +++ b/composer.json @@ -0,0 +1,32 @@ +{ + "name": "adhocore/cli", + "description": "Command line interface library for PHP", + "type": "library", + "keywords": [ + "php", "command", "argv-parser", "cli", "cli-color", "cli-action", "console", + "cli-writer", "argument-parser", "cli-option" + ], + "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": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + } +} 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/src/Argument.php b/src/Argument.php new file mode 100644 index 0000000..910cb1a --- /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); + } +} diff --git a/src/ArgvParser.php b/src/ArgvParser.php new file mode 100644 index 0000000..8a87b5c --- /dev/null +++ b/src/ArgvParser.php @@ -0,0 +1,218 @@ + + * @license MIT + * + * @link https://github.com/adhocore/cli + */ +class ArgvParser extends Parser +{ + use InflectsString; + + /** @var string */ + protected $_version; + + /** @var string */ + protected $_name; + + /** @var string */ + protected $_desc; + + /** @var callable[] Events for options */ + protected $_events = []; + + /** @var bool Whether to allow unknown (not registered) options */ + protected $_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; + $this->_allowUnknown = $allowUnknown; + + $this->option('-h, --help', 'Show help')->on([$this, 'showHelp']); + $this->option('-V, --version', 'Show version')->on([$this, 'showVersion']); + } + + /** + * Sets version. + * + * @param string $version + * + * @return self + */ + public function version(string $version): self + { + $this->_version = $version; + + 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 + * @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 + { + $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; + } + + /** + * Sets event handler for last option. + * + * @param callable $fn + * + * @return self + */ + public function on(callable $fn): self + { + \end($this->_options); + + $this->_events[\key($this->_options)] = $fn; + + return $this; + } + + protected function handleUnknown(string $arg, string $value = null) + { + if ($this->_allowUnknown) { + $this->_values[$this->toCamelCase($arg)] = $value; + + return; + } + + $values = \array_filter($this->_values, function ($value) { + return $value !== null; + }); + + // Has some value, error! + if ($values) { + throw new \RuntimeException( + \sprintf('Option "%s" not registered', $arg) + ); + } + + // Has no value, show help! + return $this->showHelp(); + } + + /** + * Get values indexed by camelized attribute name. + * + * @param bool $withDefaults + * + * @return array + */ + public function values(bool $withDefaults = true): array + { + $values = $this->_values; + + if (!$withDefaults) { + unset($values['help'], $values['version']); + } + + return $values; + } + + /** + * 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; + } + + protected function showHelp() + { + echo "{$this->_name}, version {$this->_version}" . PHP_EOL; + + // @todo: build help msg! + echo "help\n"; + + _exit(); + } + + protected function showVersion() + { + echo $this->_version . PHP_EOL; + + _exit(); + } + + protected function emit(string $event) + { + if (empty($this->_events[$event])) { + return; + } + + $callback = $this->_events[$event]; + + $callback(); + } +} + +// @codeCoverageIgnoreStart +if (!\function_exists(__NAMESPACE__ . '\\_exit')) { + function _exit($code = 0) + { + exit($code); + } +} +// @codeCoverageIgnoreEnd diff --git a/src/Color.php b/src/Color.php new file mode 100644 index 0000000..f73d3c4 --- /dev/null +++ b/src/Color.php @@ -0,0 +1,196 @@ + + * @license MIT + * + * @link static https://github.com/adhocore/cli + */ +class Color +{ + 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 $format = "\033[:bold:;:fg:;:bg:m:text:\033[0m"; + + /** @vstatic ar array Custom styles */ + protected static $styles = []; + + public function comment(string $text, array $style = [], bool $eol = false) + { + return $this->line($text, ['fg' => static::BLACK, 'bold' => 1] + $style, $eol); + } + + public function error(string $text, array $style = [], bool $eol = false) + { + return $this->line($text, ['fg' => static::RED] + $style, $eol); + } + + public function ok(string $text, array $style = [], bool $eol = false) + { + return $this->line($text, ['fg' => static::GREEN] + $style, $eol); + } + + public function warn(string $text, array $style = [], bool $eol = false) + { + 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); + } + + /** + * Returns a formatted/colored line. + * + * @param string $text + * @param array $style + * @param bool $eol End of line + * + * @return string + */ + 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:', '', $this->format) + : $this->format; + + $line = \strtr($format, [ + ':bold:' => (int) $style['bold'], + ':fg:' => (int) $style['fg'], + ':bg:' => (int) $style['bg'] + 10, + ':text:' => (string) $text, + ]); + + // Allow `Color::line('msg', [true])` instead of `Color::line('msg', [], true)` + if ($eol || !empty($style[0])) { + $line .= $this->eol(); + } + + return $line; + } + + public function eol() + { + return PHP_EOL; + } + + /** + * Register a custom style. + * + * @param string $name Example: 'alert' + * @param array $style Example: ['fg' => Color::RED, 'bg' => Color::YELLOW, 'bold' => true] + * + * @return void + */ + public static function style(string $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; + } + + /** + * Magically build styles. + * + * @param string $name Example: 'boldError', 'bgGreenBold' etc + * @param array $arguments + * + * @return string + */ + public function __call(string $name, array $arguments) + { + if (!isset($arguments[0])) { + throw new \InvalidArgumentException('Text required'); + } + + list($name, $text, $style, $eol) = $this->parseCall($name, $arguments); + + if (isset(static::$styles[$name])) { + return $this->line($text, $style + static::$styles[$name], $eol); + } + + if (\defined($color = static::class . '::' . \strtoupper($name))) { + $name = 'line'; + $style += ['fg' => \constant($color)]; + } + + if (!\method_exists($this, $name)) { + throw new \InvalidArgumentException(\sprintf('Style "%s" not defined', $name)); + } + + return $this->{$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) = $this->buildStyle($name, $style, $matches); + + return [$name, $text, $style, $eol]; + } + + /** + * 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) + { + 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]; + } +} 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); + } +} diff --git a/src/Option.php b/src/Option.php new file mode 100644 index 0000000..590bf3f --- /dev/null +++ b/src/Option.php @@ -0,0 +1,127 @@ + + * @license MIT + * + * @link https://github.com/adhocore/cli + */ +class Option +{ + use InflectsString; + + protected $short; + + protected $long; + + protected $desc; + + protected $rawCmd; + + protected $default; + + protected $required = true; + + protected $optional = false; + + protected $variadic = false; + + protected $filter; + + 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; + $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(string $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(): string + { + return $this->long; + } + + public function short(): string + { + return $this->short; + } + + public function name(): string + { + return \str_replace(['--', 'no-', 'with-'], '', $this->long); + } + + public function attributeName(): string + { + return $this->toCamelCase($this->name()); + } + + public function is($arg): bool + { + return $this->short === $arg || $this->long === $arg; + } + + public function required(): bool + { + return $this->required; + } + + public function optional(): bool + { + return $this->optional; + } + + public function variadic(): bool + { + return $this->variadic; + } + + public function default() + { + return $this->default; + } + + public function bool(): bool + { + return \preg_match('/\-no|\-with/', $this->long) > 0; + } + + public function filter($raw) + { + if ($this->filter) { + $callback = $this->filter; + + return $callback($raw); + } + + return $raw; + } +} diff --git a/src/Parser.php b/src/Parser.php new file mode 100644 index 0000000..7d2e459 --- /dev/null +++ b/src/Parser.php @@ -0,0 +1,202 @@ + + * @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 $item) { + if (!$item->required()) { + continue; + } + + list($name, $label) = [$item->name(), 'Argument']; + if ($item instanceof Option) { + list($name, $label) = [$item->long(), 'Option']; + } + + if (\in_array($this->_values[$item->attributeName()], [null, []])) { + throw new \RuntimeException( + \sprintf('%s "%s" is required', $label, $name) + ); + } + } + } +} diff --git a/src/Writer.php b/src/Writer.php new file mode 100644 index 0000000..d1ceebc --- /dev/null +++ b/src/Writer.php @@ -0,0 +1,58 @@ + + * @license MIT + * + * @link https://github.com/adhocore/cli + */ +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. + * + * @param string $name Like `red`, `bgRed`, 'bold', `error` etc + * + * @return self + */ + public function __get(string $name): self + { + 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(string $text, bool $eol = false) + { + list($method, $this->method) = [$this->method ?: 'line', '']; + + $stream = \stripos($method, 'error') !== false ? \STDERR : \STDOUT; + + \fwrite($stream, $this->colorizer->{$method}($text, [], $eol)); + } +} diff --git a/tests/ArgvParserTest.php b/tests/ArgvParserTest.php new file mode 100644 index 0000000..5a16027 --- /dev/null +++ b/tests/ArgvParserTest.php @@ -0,0 +1,212 @@ +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'; + } + + 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); + } +} 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], + ]; + } +} 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']; + } +} 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; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..385ce9c --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,10 @@ + [ + '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 "--virtual" is required'], + ], + [ + 'argv' => [''], + 'throws' => [\RuntimeException::class, 'Option "--virtual" is required'], + ], + [ + 'argv' => ['-x', 1], + 'throws' => [\RuntimeException::class, 'Option "--virtual" is required'], + ], + [ + 'argv' => ['-x', 1], + 'throws' => [\RuntimeException::class, 'Option "--virtual" is required'], + ], + [ + 'argv' => ['-v'], + 'throws' => [\RuntimeException::class, 'Option "--virtual" is required'], + ], + [ + 'argv' => ['--virtual'], + 'throws' => [\RuntimeException::class, 'Option "--virtual" is required'], + ], + ], +];