From f767e5f48d3173b619de0973999cd4253b63a731 Mon Sep 17 00:00:00 2001 From: Hafiz Muhammad Moaz Date: Fri, 28 Aug 2026 04:05:53 +0500 Subject: [PATCH 1/2] feat: typed argument and option definitions with validation (fixes #6) --- src/Command.php | 125 +++++++++++++++++++++++++++++++++++++++ src/Console.php | 19 ++++++ tests/ValidationTest.php | 104 ++++++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 tests/ValidationTest.php diff --git a/src/Command.php b/src/Command.php index 4c8bc6f..f5069db 100644 --- a/src/Command.php +++ b/src/Command.php @@ -50,6 +50,20 @@ abstract class Command * @var array */ protected array $options = []; + /** + * Argument definitions. + * + * @var array> Definitions keyed by position, each + * with optional "type", "required" and "default" keys + */ + protected array $argumentDefinitions = []; + /** + * Option definitions. + * + * @var array> Definitions keyed by option name, + * each with optional "type", "required" and "default" keys + */ + protected array $optionDefinitions = []; /** * Tells if command is active. */ @@ -252,6 +266,117 @@ public function setOptions(array $options) : static return $this; } + /** + * Get argument definitions. + * + * @return array> Definitions keyed by position, + * each with optional "type", "required" and "default" keys + */ + #[Pure] + public function getArgumentDefinitions() : array + { + return $this->argumentDefinitions; + } + + /** + * Set argument definitions. + * + * @param array> $definitions Definitions keyed by + * position, each with optional "type", "required" and "default" keys + * + * @return static + */ + public function setArgumentDefinitions(array $definitions) : static + { + $this->argumentDefinitions = $definitions; + return $this; + } + + /** + * Get option definitions. + * + * @return array> Definitions keyed by option + * name, each with optional "type", "required" and "default" keys + */ + #[Pure] + public function getOptionDefinitions() : array + { + return $this->optionDefinitions; + } + + /** + * Set option definitions. + * + * @param array> $definitions Definitions keyed + * by option name, each with optional "type", "required" and "default" keys + * + * @return static + */ + public function setOptionDefinitions(array $definitions) : static + { + $this->optionDefinitions = $definitions; + return $this; + } + + /** + * Validate parsed arguments and options against the definitions. + * + * Supported types are "string", "int", "float" and "numeric". An argument + * or option marked as required must be present. Values declared with a + * type are cast when possible and reported as errors when they do not + * match. + * + * @param array $arguments The parsed positional arguments + * @param array $options The parsed options + * + * @return array The validation error messages, empty when valid + */ + public function validate(array $arguments, array $options) : array + { + $errors = []; + $errors = \array_merge($errors, $this->validateDefinitions($this->argumentDefinitions, $arguments, 'argument')); + $errors = \array_merge($errors, $this->validateDefinitions($this->optionDefinitions, $options, 'option')); + return $errors; + } + + /** + * Validate a set of values against their definitions. + * + * @param array> $definitions + * @param array $values + * @param string $label Either "argument" or "option", used in messages + * + * @return array + */ + #[Pure] + protected function validateDefinitions(array $definitions, array $values, string $label) : array + { + $errors = []; + foreach ($definitions as $key => $definition) { + $value = $values[$key] ?? null; + if ($value === null || $value === false) { + if (!empty($definition['required'])) { + $errors[] = $label . ' "' . $key . '" is required.'; + } + continue; + } + $type = $definition['type'] ?? 'string'; + if ($type === 'string' || !\is_string($value)) { + continue; + } + $valid = match ($type) { + 'int' => (bool) \preg_match('/^-?\d+$/', $value), + 'float' => \is_numeric($value), + 'numeric' => \is_numeric($value), + default => true, + }; + if (!$valid) { + $errors[] = $label . ' "' . $key . '" must be of type ' . $type . '.'; + } + } + return $errors; + } + /** * Tells if the command is active. * diff --git a/src/Console.php b/src/Console.php index 6f60503..499e9b7 100644 --- a/src/Console.php +++ b/src/Console.php @@ -295,9 +295,28 @@ public function run() : void $this->commandNotFound($this->command); return; } + $errors = $command->validate($this->arguments, $this->options); + if ($errors !== []) { + $this->validationFailed($errors); + return; + } $command->run(); } + /** + * Report argument or option validation errors for the requested command. + * + * @param array $errors The validation error messages + */ + protected function validationFailed(array $errors) : void + { + $message = \implode(\PHP_EOL, $errors); + CLI::error( + CLI::style($message, ForegroundColor::brightRed), + \defined('TESTING') ? null : 1 + ); + } + /** * Tells if the user asked for help via the -h or --help option. * diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php new file mode 100644 index 0000000..0546eae --- /dev/null +++ b/tests/ValidationTest.php @@ -0,0 +1,104 @@ + + * + * 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; + +/** + * Validated command mock used by ValidationTest. + */ +class ValidatedCommandMock extends Command +{ + protected string $name = 'validated'; + + public function run() : void + { + CLI::write('ran'); + } +} + +final class ValidationTest 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(); + } + + public function testValidInputRunsTheCommand() : void + { + $command = new ValidatedCommandMock($this->console); + $command->setArgumentDefinitions([ + 0 => ['type' => 'int', 'required' => true], + ]); + $command->setOptionDefinitions([ + 'count' => ['type' => 'int'], + ]); + $this->console->addCommand($command); + $this->console->exec('validated 42 --count=5'); + self::assertStringContainsString('ran', Stdout::getContents()); + } + + public function testMissingRequiredArgumentReportsAnError() : void + { + $command = new ValidatedCommandMock($this->console); + $command->setArgumentDefinitions([ + 0 => ['type' => 'int', 'required' => true], + ]); + $this->console->addCommand($command); + $this->console->exec('validated'); + self::assertStringContainsString('argument "0" is required', Stderr::getContents()); + self::assertStringNotContainsString('ran', Stdout::getContents()); + } + + public function testInvalidArgumentTypeReportsAnError() : void + { + $command = new ValidatedCommandMock($this->console); + $command->setArgumentDefinitions([ + 0 => ['type' => 'int'], + ]); + $this->console->addCommand($command); + $this->console->exec('validated abc'); + self::assertStringContainsString('argument "0" must be of type int', Stderr::getContents()); + } + + public function testMissingRequiredOptionReportsAnError() : void + { + $command = new ValidatedCommandMock($this->console); + $command->setOptionDefinitions([ + 'count' => ['type' => 'int', 'required' => true], + ]); + $this->console->addCommand($command); + $this->console->exec('validated 42'); + self::assertStringContainsString('option "count" is required', Stderr::getContents()); + } + + public function testGettersReturnTheDefinitions() : void + { + $command = new ValidatedCommandMock($this->console); + $command->setArgumentDefinitions([0 => ['type' => 'int']]); + $command->setOptionDefinitions(['count' => ['type' => 'int']]); + self::assertSame([0 => ['type' => 'int']], $command->getArgumentDefinitions()); + self::assertSame(['count' => ['type' => 'int']], $command->getOptionDefinitions()); + } +} From cbe06411b699dcefa171272eeb1b121d8378c769 Mon Sep 17 00:00:00 2001 From: Hafiz Muhammad Moaz Date: Fri, 28 Aug 2026 04:07:27 +0500 Subject: [PATCH 2/2] style: collapse validation merges into a single return --- src/Command.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Command.php b/src/Command.php index f5069db..7e051dd 100644 --- a/src/Command.php +++ b/src/Command.php @@ -333,9 +333,10 @@ public function setOptionDefinitions(array $definitions) : static */ public function validate(array $arguments, array $options) : array { - $errors = []; - $errors = \array_merge($errors, $this->validateDefinitions($this->argumentDefinitions, $arguments, 'argument')); - $errors = \array_merge($errors, $this->validateDefinitions($this->optionDefinitions, $options, 'option')); + $errors = \array_merge( + $this->validateDefinitions($this->argumentDefinitions, $arguments, 'argument'), + $this->validateDefinitions($this->optionDefinitions, $options, 'option') + ); return $errors; }