diff --git a/README.md b/README.md index c157502..f540532 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,23 @@ php app greet Alice -s # HELLO, ALICE! php app help greet # auto generated usage output ``` -`run()` is invoked automatically. The `Console` parses argv for you: everything before the first option is available via `getArgument()`. Only long options carry a value (`--option=value`); short options like `-o` are always boolean flags, so `-o value` sets `o` to `true` and pushes `value` into the arguments. Commands can also declare `setAliases()` to be reachable by multiple names and `setGroup()` to organize them in the `index` listing. +`run()` is invoked automatically. The `Console` parses argv for you: positional values are available via `getArgument()` and options via `getOption()`. + +An option always takes a value written with an equal sign, as in `--option=value`. It also takes the next token, as in `-o value` and `--option value`, when the command declares that option in `$optionDefinitions` with a type other than `flag`: + +```php +protected array $optionDefinitions = [ + 'h' => ['type' => 'string', 'description' => 'The host to bind.'], + 'port' => ['type' => 'int', 'default' => 8080], + 'v' => ['type' => 'flag'], +]; +``` + +```bash +php app serve -h 0.0.0.0 --port 8080 # h => 0.0.0.0, port => 8080 +``` + +An option with no definition, or one defined as a `flag`, is set to `true` and the next token becomes an argument. A negative number such as `-5` is read as an argument, so it does not need the `--` end of options marker. Commands can also declare `setAliases()` to be reachable by multiple names and `setGroup()` to organize them in the `index` listing. ## Installation ```bash diff --git a/src/Console.php b/src/Console.php index 2ef8875..3b5eca8 100644 --- a/src/Console.php +++ b/src/Console.php @@ -46,6 +46,14 @@ class Console * @var array */ protected array $arguments = []; + /** + * The input tokens left after the script name and the command name were + * taken out, kept so the options can be parsed again once the command, + * and with it the option definitions, is known. + * + * @var array + */ + protected array $tokens = []; /** * The Language instance. */ @@ -432,6 +440,7 @@ protected function dispatch() : int $this->commandNotFound($this->command); return $this->exitCode; } + $this->reparseWithOptions($command); $errors = $command->validate($this->arguments, $this->options); if ($errors !== []) { $this->validationFailed($errors); @@ -592,6 +601,7 @@ protected function reset() : void $this->command = ''; $this->options = []; $this->arguments = []; + $this->tokens = []; } /** @@ -604,9 +614,13 @@ protected function reset() : void * [command] [options] -- [arguments] * Short option: -l, -la === l = true, a = true * Long option: --list, --all=vertical === list = true, all = vertical - * Only Long Options receive values: + * Options always receive values with an equal sign: * --foo=bar or --f=bar - "foo" and "f" are bar * -foo=bar or -f=bar - all characters are true (f, o, =, b, a, r) + * An option the command declares in its option definitions with a type + * other than "flag" also receives the next token as value, so -o value + * and --opt value work. Options without a definition stay true. + * A token that is a negative number, like -5, is an argument. * After -- all values are arguments, also if is prefixed with - * Without --, arguments and options can be mixed: -ls foo -x abc --a=e. * @@ -620,13 +634,32 @@ protected function prepare(array $argumentValues) : void $this->command = $argumentValues[1]; unset($argumentValues[1]); } + $this->tokens = \array_values($argumentValues); + $this->parseTokens(); + $this->previousQuiet = CLI::isQuiet(); + $this->previousAnsi = CLI::isAnsi(); + $this->applyGlobalOptions(); + } + + /** + * Parse the prepared tokens into options and arguments. + * + * @param array $valueOptions Names of the options that take + * the next token as their value + */ + protected function parseTokens(array $valueOptions = []) : void + { + $this->options = []; + $this->arguments = []; $endOptions = false; - foreach ($argumentValues as $value) { + $total = \count($this->tokens); + for ($index = 0; $index < $total; $index++) { + $value = $this->tokens[$index]; if ($endOptions === false && $value === '--') { $endOptions = true; continue; } - if ($endOptions === false && $value !== '' && $value[0] === '-') { + if ($endOptions === false && static::isOptionToken($value)) { if (isset($value[1]) && $value[1] === '-') { $option = \substr($value, 2); if (\str_contains($option, '=')) { @@ -634,10 +667,25 @@ protected function prepare(array $argumentValues) : void $this->options[$option] = $value; continue; } + if (\in_array($option, $valueOptions, true) + && $this->hasValueAt($index + 1)) { + $index++; + $this->options[$option] = $this->tokens[$index]; + continue; + } $this->options[$option] = true; continue; } - foreach (\str_split(\substr($value, 1)) as $item) { + $items = \str_split(\substr($value, 1)); + $lastItem = \array_key_last($items); + foreach ($items as $position => $item) { + if ($position === $lastItem + && \in_array($item, $valueOptions, true) + && $this->hasValueAt($index + 1)) { + $index++; + $this->options[$item] = $this->tokens[$index]; + continue; + } $this->options[$item] = true; } continue; @@ -645,11 +693,40 @@ protected function prepare(array $argumentValues) : void //$endOptions = true; $this->arguments[] = $value; } - $this->previousQuiet = CLI::isQuiet(); - $this->previousAnsi = CLI::isAnsi(); + } + + /** + * Parse the options again now that the command, and with it the option + * definitions telling which options take a value, is known. + * + * @param Command $command The command about to be dispatched + */ + protected function reparseWithOptions(Command $command) : void + { + $valueOptions = static::valueOptionNames($command); + if ($valueOptions === []) { + return; + } + $this->parseTokens($valueOptions); $this->applyGlobalOptions(); } + /** + * Tells if the token at a given position can be taken as an option value. + * + * @param int $index The token position + * + * @return bool False when there is no token left or the token is itself + * an option or the end of options marker + */ + #[Pure] + protected function hasValueAt(int $index) : bool + { + return isset($this->tokens[$index]) + && $this->tokens[$index] !== '--' + && !static::isOptionToken($this->tokens[$index]); + } + /** * Apply the console wide options like quiet mode and disabling ANSI colors. */ @@ -666,6 +743,43 @@ protected function applyGlobalOptions() : void } } + /** + * Tells if a token is an option and not an argument. + * + * A negative number is an argument, so -5 can be passed without the -- + * end of options marker. + * + * @param string $token The input token + * + * @return bool + */ + #[Pure] + protected static function isOptionToken(string $token) : bool + { + return $token !== '' && $token[0] === '-' && !\is_numeric($token); + } + + /** + * List the option names a command declares with a type other than "flag", + * which are the ones able to take the next token as their value. + * + * @param Command $command The command to inspect + * + * @return array Names without their leading dashes + */ + #[Pure] + protected static function valueOptionNames(Command $command) : array + { + $names = []; + foreach ($command->getOptionDefinitions() as $key => $definition) { + if (($definition['type'] ?? 'string') === 'flag') { + continue; + } + $names[] = \ltrim(\trim((string) $key), '-'); + } + return $names; + } + /** * List the short and long option names a command declares for itself. * diff --git a/tests/OptionValueTest.php b/tests/OptionValueTest.php new file mode 100644 index 0000000..7fecd3e --- /dev/null +++ b/tests/OptionValueTest.php @@ -0,0 +1,212 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Tests\CLI; + +use Framework\CLI\CLI; +use Framework\CLI\Command; +use Framework\CLI\Streams\Stderr; +use Framework\CLI\Streams\Stdout; +use PHPUnit\Framework\TestCase; + +/** + * Command mock used by OptionValueTest. + */ +class OptionValueCommandMock extends Command +{ + protected string $name = 'serve'; + + public function run() : void + { + CLI::write('ran'); + } +} + +final class OptionValueTest extends TestCase +{ + protected ConsoleMock $console; + + protected function setUp() : void + { + Stdout::init(); + Stderr::init(); + $this->console = new ConsoleMock(); + } + + protected function tearDown() : void + { + Stdout::reset(); + Stderr::reset(); + } + + /** + * Register the "serve" command with the given option definitions. + * + * @param array> $definitions + * + * @return OptionValueCommandMock + */ + protected function serveCommand(array $definitions) : OptionValueCommandMock + { + $command = new OptionValueCommandMock($this->console); + $command->setOptionDefinitions($definitions); + $this->console->addCommand($command); + return $command; + } + + public function testShortOptionTakesTheNextTokenAsValue() : void + { + $this->serveCommand([ + 'h' => ['type' => 'string'], + ]); + $this->console->exec('serve -h 0.0.0.0'); + self::assertSame('0.0.0.0', $this->console->getOption('h')); + self::assertSame([], $this->console->getArguments()); + } + + public function testLongOptionTakesTheNextTokenAsValue() : void + { + $this->serveCommand([ + 'port' => ['type' => 'int'], + ]); + $this->console->exec('serve --port 8080'); + self::assertSame('8080', $this->console->getOption('port')); + self::assertSame([], $this->console->getArguments()); + } + + public function testUndefinedOptionKeepsTheBooleanBehaviour() : void + { + $this->serveCommand([]); + $this->console->exec('serve -x 0.0.0.0'); + self::assertTrue($this->console->getOption('x')); + self::assertSame([ + '0.0.0.0', + ], $this->console->getArguments()); + } + + public function testFlagOptionDoesNotTakeTheNextToken() : void + { + $this->serveCommand([ + 'all' => ['type' => 'flag'], + ]); + $this->console->exec('serve --all now'); + self::assertTrue($this->console->getOption('all')); + self::assertSame([ + 'now', + ], $this->console->getArguments()); + } + + public function testValueOptionDoesNotTakeAnotherOption() : void + { + $this->serveCommand([ + 'port' => ['type' => 'int'], + 'verbose' => ['type' => 'flag'], + ]); + $this->console->exec('serve --port --verbose'); + self::assertTrue($this->console->getOption('port')); + self::assertTrue($this->console->getOption('verbose')); + } + + public function testValueOptionDoesNotTakeTheEndOfOptionsMarker() : void + { + $this->serveCommand([ + 'port' => ['type' => 'int'], + ]); + $this->console->exec('serve --port -- 8080'); + self::assertTrue($this->console->getOption('port')); + self::assertSame([ + '8080', + ], $this->console->getArguments()); + } + + public function testShortGroupTakesTheNextTokenForItsLastOption() : void + { + $this->serveCommand([ + 'v' => ['type' => 'flag'], + 'f' => ['type' => 'string'], + ]); + $this->console->exec('serve -vf config.php'); + self::assertTrue($this->console->getOption('v')); + self::assertSame('config.php', $this->console->getOption('f')); + self::assertSame([], $this->console->getArguments()); + } + + public function testEqualSignKeepsPrecedenceOverTheNextToken() : void + { + $this->serveCommand([ + 'port' => ['type' => 'int'], + ]); + $this->console->exec('serve --port=8080 public'); + self::assertSame('8080', $this->console->getOption('port')); + self::assertSame([ + 'public', + ], $this->console->getArguments()); + } + + public function testArgumentsKeepTheirPositionsAfterAValueIsTaken() : void + { + $command = $this->serveCommand([ + 'port' => ['type' => 'int'], + ]); + $command->setArgumentDefinitions([ + 0 => ['type' => 'string', 'required' => true], + ]); + $this->console->exec('serve --port 8080 public'); + self::assertSame('8080', $this->console->getOption('port')); + self::assertSame([ + 'public', + ], $this->console->getArguments()); + self::assertStringContainsString('ran', Stdout::getContents()); + } + + public function testGlobalOptionsAreStillRemovedAfterTheReparse() : void + { + $this->serveCommand([ + 'port' => ['type' => 'int'], + ]); + $this->console->exec('serve --port 8080 --quiet'); + self::assertArrayNotHasKey('quiet', $this->console->getOptions()); + } + + public function testNegativeIntegerIsAnArgument() : void + { + $this->console->prepare([ + 'file.php', + 'calc', + '-5', + ]); + self::assertSame([], $this->console->getOptions()); + self::assertSame([ + '-5', + ], $this->console->getArguments()); + } + + public function testNegativeFloatIsAnArgument() : void + { + $this->console->prepare([ + 'file.php', + 'calc', + '-1.5', + ]); + self::assertSame([], $this->console->getOptions()); + self::assertSame([ + '-1.5', + ], $this->console->getArguments()); + } + + public function testNegativeNumberIsTakenAsAnOptionValue() : void + { + $this->serveCommand([ + 'offset' => ['type' => 'int'], + ]); + $this->console->exec('serve --offset -5'); + self::assertSame('-5', $this->console->getOption('offset')); + self::assertSame([], $this->console->getArguments()); + } +}