Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions src/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ abstract class Command
* @var array<string,string>
*/
protected array $options = [];
/**
* Argument definitions.
*
* @var array<int,array<string,mixed>> Definitions keyed by position, each
* with optional "type", "required" and "default" keys
*/
protected array $argumentDefinitions = [];
/**
* Option definitions.
*
* @var array<string,array<string,mixed>> Definitions keyed by option name,
* each with optional "type", "required" and "default" keys
*/
protected array $optionDefinitions = [];
/**
* Tells if command is active.
*/
Expand Down Expand Up @@ -252,6 +266,118 @@ public function setOptions(array $options) : static
return $this;
}

/**
* Get argument definitions.
*
* @return array<int,array<string,mixed>> 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<int,array<string,mixed>> $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<string,array<string,mixed>> 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<string,array<string,mixed>> $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<int,string> $arguments The parsed positional arguments
* @param array<string,bool|string> $options The parsed options
*
* @return array<int,string> The validation error messages, empty when valid
*/
public function validate(array $arguments, array $options) : array
{
$errors = \array_merge(
$this->validateDefinitions($this->argumentDefinitions, $arguments, 'argument'),
$this->validateDefinitions($this->optionDefinitions, $options, 'option')
);
return $errors;
}

/**
* Validate a set of values against their definitions.
*
* @param array<int|string,array<string,mixed>> $definitions
* @param array<int|string,bool|string> $values
* @param string $label Either "argument" or "option", used in messages
*
* @return array<int,string>
*/
#[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.
*
Expand Down
19 changes: 19 additions & 0 deletions src/Console.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int,string> $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.
*
Expand Down
104 changes: 104 additions & 0 deletions tests/ValidationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php
/*
* This file is part of Webisters CLI Library.
*
* (c) Hafiz Muhammad Moaz <thewebisters@gmail.com>
*
* 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());
}
}
Loading