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 ...
+
+[](https://github.com/adhocore/cli/releases)
+[](https://travis-ci.org/adhocore/cli?branch=master)
+[](https://scrutinizer-ci.com/g/adhocore/cli/?branch=master)
+[](https://codecov.io/gh/adhocore/cli)
+[](https://styleci.io/repos/139012552)
+[](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'],
+ ],
+ ],
+];