From 58560521e04e6d5c903f1cc6854fca8deed4fc7f Mon Sep 17 00:00:00 2001 From: Hafiz Muhammad Moaz Date: Mon, 7 Sep 2026 18:08:53 +0500 Subject: [PATCH] feat: space separated option values (fixes #48) An option a command declares in its option definitions with a type other than "flag" now takes the next token as its value, so -o value and --opt value work next to --opt=value. Options with no definition keep returning true and leave the next token in the arguments. The tokens are kept after prepare() so the options can be parsed again in dispatch(), once the command and its definitions are known. A token that is a negative number, like -5, is now an argument instead of a set of boolean options, so it no longer needs the -- marker. --- README.md | 18 +++- src/Console.php | 126 ++++++++++++++++++++-- tests/OptionValueTest.php | 212 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 tests/OptionValueTest.php 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()); + } +}