diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f524d..dc1ac0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,92 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). -**View all [Unreleased][] changes here** +## [1.2.2][] +#### Changed +- Minor improvement to logic in `AbstractInputHandler::validateInput()`. Ensures that an input with a validator, but with a default value and no user suplied input, will have the default value used. + +## [1.2.1][] +#### Changed +- Updated `InputHandlerFactory` and `InputTypeFactory` to work with changes in `pointybeard/helpers-foundation-factory` 1.0.2 + +## [1.2.0][] +#### Added +- Added `InputTypeFilterIterator` class +- Added `UnrecognisedInputException` exception + +#### Changed +- `InputCollection` now implements `Iterator` and `Countable` (implementing required methods) +- Removed use of `$type` in `InputCollection` +- Added `InputCollection::getItemsExcludeByType()` +- `InputCollection::getItemsByType()` and `InputCollection::getItems()` now returns an `Iterator` +- Renamed `InputCollection::append()` to `add()` and added `$position` flag +- Added `POSITION_APPEND` and `POSITION_PREPEND` flags to `InputCollection` +- Made `getCollection()`, `getInput()`, `find()`, and `validate()` in `AbstractInputHandler` final +- Removed all categorisation of items by type in `AbstractInputHandler::$input` +- Abstracted most of `AbstractInputHandler::validate()` into it's own protected method called `validateInput()` +- Removed `$skipValidation` argument from `AbstractInputHandler::bind()` and relaced with `$flags` +- Added `FLAG_BIND_SKIP_VALIDATION`, `FLAG_VALIDATION_SKIP_REQUIRED`, `FLAG_VALIDATION_SKIP_CUSTOM`, and `FLAG_VALIDATION_SKIP_UNRECOGNISED` flags to `AbstractInputHandler` +- Added check in `AbstractInputHandler::validate()` to look for unrecognised options and arguments +- Removed `InputHandlerFactory::FLAG_SKIP_VALIDATION` from `InputHandlerFactory` +- Passing flags in call from `InputHandlerFactory::build()` to `AbstractInputHandler::bind()` +- Updated `InputHandlerInterface::bind()` and `validate()` methods to support flags + +## [1.1.4][] +#### Fixed +- Fixed misnamed variable in `InputCollection::merge()` + +## [1.1.3][] +#### Fixed +- Fixed logic bug that prevented `$index` and `$type` from being set in `InputCollection::append()`. This means replaceing items in an `InputCollection` now works as expected + +## [1.1.2][] +#### Added +- Added `InputValidationFailedException` exception +- Added `InputTypeInterface::getDisplayName()` method to standardise how the name of an `InputTypeInterface` class wants to display it's name + +#### Changed +- Updated validation logic for inputs that have a validator, no default, and are not set +- Throwing `InputValidationFailedException` exception when validation fails +- Updated `RequiredInputMissingException` and `RequiredInputMissingValueException` exceptions to use `InputTypeInterface::getDisplayName()` when producing their message +- Removed unused `RequiredArgumentMissingException` exception + +## [1.1.1][] +#### Changed +- `AbstractInputHandler::find()` returns NULL if it cannot find any input with the supplied name. It is easier to test for NULL than it is to catch an exception + +## [1.1.0][] +#### Added +- Expanded input types to include `Flag`, `IncrementingFlag`, and `LongOption` +- Added `InputTypeFactory` to help with loading input type classes + +#### Changed +- Updated to work with more than just `Argument` and `Option` input types. Makes use of `InputTypeFactory` to allow addition of new types as needed + +## [1.0.2][] +#### Changed +- Updated example to reflect changes to `manpage()` function in `pointybeard/helpers-functions-cli` package +- Refactoring and improvemnts to `Argument::__toString()` and `Option::__toString()` + +## [1.0.2][] +#### Fixed +- Fixed `InputCollection::getArgumentsByIndex()` so it returns NULL if the index does not exist instead of throwing an E_NOTICE message + +## [1.0.1][] +#### Changed +- Updated example to use `Cli\manpage()` provided by the `pointybeard/helpers-functions-cli` package ## 1.0.0 #### Added - Initial release -[Unreleased]: https://github.com/pointybeard/helpers-cli-input/compare/1.0.0...integration +[1.2.2]: https://github.com/pointybeard/helpers-functions-cli/compare/1.2.1...1.2.2 +[1.2.1]: https://github.com/pointybeard/helpers-functions-cli/compare/1.2.0...1.2.1 +[1.2.0]: https://github.com/pointybeard/helpers-functions-cli/compare/1.1.4...1.2.0 +[1.1.4]: https://github.com/pointybeard/helpers-functions-cli/compare/1.1.3...1.1.4 +[1.1.3]: https://github.com/pointybeard/helpers-functions-cli/compare/1.1.2...1.1.3 +[1.1.2]: https://github.com/pointybeard/helpers-functions-cli/compare/1.1.1...1.1.2 +[1.1.1]: https://github.com/pointybeard/helpers-functions-cli/compare/1.1.0...1.1.1 +[1.1.0]: https://github.com/pointybeard/helpers-functions-cli/compare/1.0.3...1.1.0 +[1.0.3]: https://github.com/pointybeard/helpers-functions-cli/compare/1.0.2...1.0.3 +[1.0.2]: https://github.com/pointybeard/helpers-functions-cli/compare/1.0.1...1.0.2 +[1.0.1]: https://github.com/pointybeard/helpers-functions-cli/compare/1.0.0...1.0.1 diff --git a/README.md b/README.md index 993641a..be26831 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # PHP Helpers: Command-line Input and Input Type Handlers -- Version: v1.0.0 -- Date: May 20 2019 +- Version: v1.2.2 +- Date: Aug 06 2019 - [Release notes](https://github.com/pointybeard/helpers-cli-input/blob/master/CHANGELOG.md) - [GitHub repository](https://github.com/pointybeard/helpers-cli-input) @@ -9,7 +9,7 @@ Collection of classes for handling argv (and other) input when calling command-l ## Installation -This library is installed via [Composer](http://getcomposer.org/). To install, use `composer require pointybeard/helpers-cli-input` or add `"pointybeard/helpers-cli-input": "~1.0"` to your `composer.json` file. +This library is installed via [Composer](http://getcomposer.org/). To install, use `composer require pointybeard/helpers-cli-input` or add `"pointybeard/helpers-cli-input": "~1.2.0"` to your `composer.json` file. And run composer to update your dependencies: diff --git a/composer.json b/composer.json index 081d489..8eea99b 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,5 @@ { "name": "pointybeard/helpers-cli-input", - "version": "1.0.0", "description": "Collection of classes for handling argv (and other) input when calling command-line scripts. Helps with parsing, collecting and validating arguments, options, and flags.", "homepage": "https://github.com/pointybeard/helpers-cli-input", "license": "MIT", @@ -17,11 +16,6 @@ "pointybeard/helpers-foundation-factory": "~1.0", "pointybeard/helpers-functions-flags": "~1.0" }, - "require-dev": { - "phpunit/phpunit": "^8", - "pointybeard/helpers-functions-strings": "~1.0", - "pointybeard/helpers-cli-colour": "~1.0" - }, "support": { "issues": "https://github.com/pointybeard/helpers-cli-input/issues", "wiki": "https://github.com/pointybeard/helpers-cli-input/wiki" diff --git a/example/example.php b/example/example.php index 075ff81..1695bf1 100644 --- a/example/example.php +++ b/example/example.php @@ -4,146 +4,72 @@ declare(strict_types=1); include __DIR__.'/../vendor/autoload.php'; use pointybeard\Helpers\Cli\Input; -use pointybeard\Helpers\Functions\Flags; -use pointybeard\Helpers\Functions\Strings; -use pointybeard\Helpers\Cli\Colour\Colour; +use pointybeard\Helpers\Functions\Cli; // Define what we are expecting to get from the command line $collection = (new Input\InputCollection()) - ->append(new Input\Types\Argument( - 'action', - Input\AbstractInputType::FLAG_REQUIRED, - 'The name of the action to perform' - )) - ->append(new Input\Types\Option( - 'v', - null, - Input\AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_TYPE_INCREMENTING, - 'verbosity level. -v (errors only), -vv (warnings and errors), -vvv (everything).', - null, - 0 - )) - ->append(new Input\Types\Option( - 'd', - 'data', - Input\AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED, - 'Path to the input JSON data', - function (Input\AbstractInputType $input, Input\AbstractInputHandler $context) { - // Make sure -d (--data) is a valid file that can be read - $file = $context->getOption('d'); + ->add( + Input\InputTypeFactory::build('Argument') + ->name('action') + ->flags(Input\AbstractInputType::FLAG_REQUIRED) + ->description('The name of the action to perform') + ) + ->add( + Input\InputTypeFactory::build('IncrementingFlag') + ->name('v') + ->flags(Input\AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_TYPE_INCREMENTING) + ->description('verbosity level. -v (errors only), -vv (warnings and errors), -vvv (everything).') + ->validator(new Input\Validator( + function (Input\AbstractInputType $input, Input\AbstractInputHandler $context) { + // Make sure verbosity level never goes above 3 + return min(3, (int) $context->find('v')); + } + )) + ) + ->add( + Input\InputTypeFactory::build('LongOption') + ->name('data') + ->short('d') + ->flags(Input\AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) + ->description('Path to the input JSON data') + ->validator(new Input\Validator( + function (Input\AbstractInputType $input, Input\AbstractInputHandler $context) { + // Make sure -d (--data) is a valid file that can be read + $file = $context->find('data'); - if (!is_readable($file)) { - throw new \Exception('The file specified via option -d (--data) does not exist or is not readable.'); - } + if (!is_readable($file)) { + throw new \Exception('The file specified via option --data does not exist or is not readable.'); + } - // Now make sure it is valid JSON - try { - $json = json_decode(file_get_contents($file), false, 512, JSON_THROW_ON_ERROR); - } catch (JsonException $ex) { - throw new \Exception(sprintf('The file specified via option -d (--data) does not appear to be a valid JSON ddocument. Returned: %s: %s', $ex->getCode(), $ex->getMessage())); - } + // Now make sure it is valid JSON + try { + $json = json_decode(file_get_contents($file), false, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $ex) { + throw new \Exception(sprintf('The file specified via option --data does not appear to be a valid JSON ddocument. Returned: %s: %s', $ex->getCode(), $ex->getMessage())); + } - return $json; - } - )) + return $json; + } + )) + ) ; // Get the supplied input. Passing the collection will make the handler bind values // and validate the input according to our collection -$argv = Input\InputHandlerFactory::build('Argv', $collection); - -// Example of using an input collection to generate a usage string -function usage(Input\InputCollection $collection): string { - $arguments = []; - foreach ($collection->getArguments() as $a) { - $arguments[] = strtoupper( - // Wrap with square brackets if it's not required - Flags\is_flag_set(Input\AbstractInputType::FLAG_OPTIONAL, $a->flags()) || - !Flags\is_flag_set(Input\AbstractInputType::FLAG_REQUIRED, $a->flags()) - ? "[{$a->name()}]" - : $a->name() - ); - } - $arguments = trim(implode($arguments, ' ')); - return sprintf( - "Usage: php -f example.php -- [OPTIONS]... %s%s", - $arguments, - strlen($arguments) > 0 ? '...' : '' - ); +try { + $argv = Input\InputHandlerFactory::build('Argv', $collection); +} catch (\Exception $ex) { + echo 'Error when attempting to bind values to collection. Returned: '.$ex->getMessage().PHP_EOL; + exit; } -// Example of using an input collection to generate a manual page -function manpage(Input\InputCollection $collection) : string { - - $arguments = $options = []; - - foreach ($collection->getArguments() as $a) { - $arguments[] = (string) $a; - } - - foreach ($collection->getOptions() as $o) { - $options[] = (string) $o; - } - - $arguments = implode($arguments, PHP_EOL.' '); - $options = implode($options, PHP_EOL.' '); - - return sprintf('%s 1.0.0, %s -%s - -Mandatory values for long options are mandatory for short options too. - -Arguments: - %s - -Options: - %s - -Examples: - php -f example/example.php -- -vvv -d example/example.json import -', - basename(__FILE__), - Strings\utf8_wordwrap( - "An example script for the PHP Helpers: Command-line Input and Input Type Handlers composer library (pointybeard/helpers-cli-input)." - ), - usage($collection), - $arguments, - $options - ); -} - -// Display the manual in green text -echo Colour::colourise(manpage($collection), Colour::FG_GREEN) . PHP_EOL . PHP_EOL; - -/* -example.php 1.0.0, An example script for the PHP Helpers: Command-line Input and Input Type Handlers -composer library (pointybeard/helpers-cli-input). -Usage: php -f example.php -- [OPTIONS]... ACTION... - -Mandatory values for long options are mandatory for short options too. - -Arguments: - ACTION The name of the action to perform - -Options: - -v verbosity level. -v (errors only), -vv (warnings - and errors), -vvv (everything). - -d, --data=VALUE Path to the input JSON data - -Examples: - php -f example/example.php -- -vvv -d example/example.json import -*/ - -var_dump($argv->getArgument('action')); +var_dump($argv->find('action')); // string(6) "import" -var_dump($argv->getOption('v')); +var_dump($argv->find('v')); //int(3) -var_dump($argv->getOption('s')); -//bool(true) - -var_dump($argv->getOption('d')); +var_dump($argv->find('data')); // class stdClass#11 (1) { // public $fruit => // array(2) { @@ -153,3 +79,6 @@ var_dump($argv->getOption('d')); // string(6) "banana" // } // } + +var_dump($argv->find('nope-doesnt-exist')); +// NULL diff --git a/src/Input/AbstractInputHandler.php b/src/Input/AbstractInputHandler.php index d35eb47..e0c5e13 100644 --- a/src/Input/AbstractInputHandler.php +++ b/src/Input/AbstractInputHandler.php @@ -5,26 +5,49 @@ declare(strict_types=1); namespace pointybeard\Helpers\Cli\Input; use pointybeard\Helpers\Functions\Flags; +use pointybeard\Helpers\Functions\Debug; abstract class AbstractInputHandler implements Interfaces\InputHandlerInterface { - protected $options = []; - protected $arguments = []; + /** + * Will skip all validation when bind() is executed. Ignores all other flags + * @var integer + */ + const FLAG_BIND_SKIP_VALIDATION = 0x0001; + + /** + * Will skip the required input and required values check + * @var integer + */ + const FLAG_VALIDATION_SKIP_REQUIRED = 0x0002; + + /** + * Will skip running custom validators + * @var integer + */ + const FLAG_VALIDATION_SKIP_CUSTOM = 0x0004; + + /** + * Will skip checking if an input is in the collection + * @var integer + */ + const FLAG_VALIDATION_SKIP_UNRECOGNISED = 0x0008; + + protected $input = []; protected $collection = null; abstract protected function parse(): bool; - public function bind(InputCollection $inputCollection, bool $skipValidation = false): bool + final public function bind(InputCollection $inputCollection, ?int $flags = null): bool { // Do the binding stuff here - $this->options = []; - $this->arguments = []; + $this->input = []; $this->collection = $inputCollection; $this->parse(); - if (true !== $skipValidation) { - $this->validate(); + if (!Flags\is_flag_set($flags, self::FLAG_BIND_SKIP_VALIDATION)) { + $this->validate($flags); } return true; @@ -41,76 +64,86 @@ abstract class AbstractInputHandler implements Interfaces\InputHandlerInterface } } - public function validate(): void - { - // Do basic missing option and value checking here - foreach ($this->collection->getOptions() as $input) { - self::checkRequiredAndRequiredValue($input, $this->options); + protected function validateInput(AbstractInputType $input, ?int $flags) { + if(!Flags\is_flag_set($flags, self::FLAG_VALIDATION_SKIP_REQUIRED)) { + self::checkRequiredAndRequiredValue($input, $this->input); } + // There is a default value and input has not been set. Assign the + // default value to the result. + if ( + null !== $input->default() && + null === $this->find($input->name()) + ) { + $result = $input->default(); - // Option validation. - foreach ($this->collection->getoptions() as $o) { - $result = false; + // Input has been set AND it has a validator. Run the validator over the + // input. Note, this will be skipped if FLAG_VALIDATION_SKIP_CUSTOM is + // set + } elseif (null !== $this->find($input->name()) && null !== $input->validator() && !Flags\is_flag_set($flags, self::FLAG_VALIDATION_SKIP_CUSTOM)) { + $validator = $input->validator(); - if (!array_key_exists($o->name(), $this->options)) { - $result = $o->default(); - } else { - if (null === $o->validator()) { - $result = $o->default(); - continue; - } elseif ($o->validator() instanceof \Closure) { - $validator = new Validator($o->validator()); - } elseif ($o->validator() instanceof Validator) { - $validator = $o->validator(); - } else { - throw new \Exception("Validator for option {$o->name()} must be NULL or an instance of either Closure or Input\Validator."); - } - - $result = $validator->validate($o, $this); + if ($validator instanceof \Closure) { + $validator = new Validator($validator); + } elseif (!($validator instanceof Validator)) { + throw new \Exception("Validator for '{$input->name()}' must be NULL or an instance of either Closure or Input\Validator."); } - $this->options[$o->name()] = $result; + try { + $result = $validator->validate($input, $this); + } catch (\Exception $ex) { + throw new Exceptions\InputValidationFailedException($input, 0, $ex); + } + + // No default, but may or may not have been set so assign whatever value + // it might have to the result + } else { + $result = $this->find($input->name()); } - // Argument validation. - foreach ($this->collection->getArguments() as $a) { - self::checkRequiredAndRequiredValue($a, $this->arguments); + return $result; + } - if (isset($this->arguments[$a->name()]) && null !== $a->validator()) { - if ($a->validator() instanceof \Closure) { - $validator = new Validator($a->validator()); - } elseif ($a->validator() instanceof Validator) { - $validator = $a->validator(); - } else { - throw new \Exception("Validator for argument {$a->name()} must be NULL or an instance of either Closure or Input\Validator."); + protected function isInputRecognised(string $name): bool { + return null === $this->collection->find($name) ? false : true; + } + + final public function validate(?int $flags = null): void + { + if(!Flags\is_flag_set($flags, self::FLAG_VALIDATION_SKIP_UNRECOGNISED)) { + foreach($this->input as $name => $value) { + if(false == static::isInputRecognised((string)$name)) { + throw new Exceptions\UnrecognisedInputException("'{$name}' is not recognised"); } - - $validator->validate($a, $this); } } + + foreach ($this->collection->getItems() as $input) { + $this->input[$input->name()] = static::validateInput($input, $flags); + } } - public function getArgument(string $name): ?string + final public function find(string $name) { - return $this->arguments[$name] ?? null; + if (isset($this->input[$name])) { + return $this->input[$name]; + } + + // Check the collection to see if anything responds to $name + foreach ($this->collection->getItems() as $item) { + if ($item->respondsTo($name) && isset($this->input[$item->name()])) { + return $this->input[$item->name()]; + } + } + + return null; } - public function getOption(string $name) + final public function getInput(): array { - return $this->options[$name] ?? null; + return $this->input; } - public function getArguments(): array - { - return $this->arguments; - } - - public function getOptions(): array - { - return $this->options; - } - - public function getCollection(): ?InputCollection + final public function getCollection(): ?InputCollection { return $this->collection; } diff --git a/src/Input/AbstractInputType.php b/src/Input/AbstractInputType.php index 9fadc5e..0d5f1af 100644 --- a/src/Input/AbstractInputType.php +++ b/src/Input/AbstractInputType.php @@ -12,22 +12,40 @@ abstract class AbstractInputType implements Interfaces\InputTypeInterface protected $flags; protected $description; protected $validator; + protected $default; protected $value; - public function __construct($name, int $flags = null, string $description = null, object $validator = null) + public function __construct(string $name = null, int $flags = null, string $description = null, object $validator = null, $default = null) { $this->name = $name; $this->flags = $flags; $this->description = $description; $this->validator = $validator; + $this->default = $default; } public function __call($name, array $args = []) + { + if (empty($args)) { + return $this->$name; + } + + $this->$name = $args[0]; + + return $this; + } + + public function __get($name) { return $this->$name; } + public function respondsTo(string $name): bool + { + return $name == $this->name; + } + public function getType(): string { return strtolower((new \ReflectionClass(static::class))->getShortName()); diff --git a/src/Input/Exceptions/InputNotFoundException.php b/src/Input/Exceptions/InputNotFoundException.php index 475cc66..f262aaa 100644 --- a/src/Input/Exceptions/InputNotFoundException.php +++ b/src/Input/Exceptions/InputNotFoundException.php @@ -4,10 +4,10 @@ declare(strict_types=1); namespace pointybeard\Helpers\Cli\Input\Exceptions; -class InputHandlerNotFoundException extends \Exception +class InputNotFoundException extends \Exception { - public function __construct(string $handler, string $command, $code = 0, \Exception $previous = null) + public function __construct(string $name, $code = 0, \Exception $previous = null) { - return parent::__construct(sprintf('The input handler %s could not be located.', $handler), $code, $previous); + return parent::__construct(sprintf('Input %s could not be found.', $name), $code, $previous); } } diff --git a/src/Input/Exceptions/InputValidationFailedException.php b/src/Input/Exceptions/InputValidationFailedException.php new file mode 100644 index 0000000..6c3414c --- /dev/null +++ b/src/Input/Exceptions/InputValidationFailedException.php @@ -0,0 +1,15 @@ +getDisplayName(), $previous->getMessage()), $code, $previous); + } +} diff --git a/src/Input/Exceptions/RequiredArgumentMissingException.php b/src/Input/Exceptions/RequiredArgumentMissingException.php deleted file mode 100644 index d2f6c7a..0000000 --- a/src/Input/Exceptions/RequiredArgumentMissingException.php +++ /dev/null @@ -1,22 +0,0 @@ -argument = strtoupper($argument); - - return parent::__construct("missing argument {$this->argument}.", $code, $previous); - } - - public function getArgumentName(): string - { - return $this->argument; - } -} diff --git a/src/Input/Exceptions/RequiredInputMissingException.php b/src/Input/Exceptions/RequiredInputMissingException.php index c13bfd4..d7c4b43 100644 --- a/src/Input/Exceptions/RequiredInputMissingException.php +++ b/src/Input/Exceptions/RequiredInputMissingException.php @@ -15,10 +15,8 @@ class RequiredInputMissingException extends \Exception $this->input = $input; return parent::__construct(sprintf( - 'missing %s %s%s', - $input->getType(), - 'option' == $input->getType() ? '-' : '', - 'option' == $input->getType() ? $input->name() : strtoupper($input->name()) + 'missing %s', + $input->getDisplayName() ), $code, $previous); } diff --git a/src/Input/Exceptions/RequiredInputMissingValueException.php b/src/Input/Exceptions/RequiredInputMissingValueException.php index b04c292..62be79b 100644 --- a/src/Input/Exceptions/RequiredInputMissingValueException.php +++ b/src/Input/Exceptions/RequiredInputMissingValueException.php @@ -15,10 +15,8 @@ class RequiredInputMissingValueException extends \Exception $this->input = $input; return parent::__construct(sprintf( - '%s %s%s is missing a value', - $input->getType(), - 'option' == $input->getType() ? '-' : '', - 'option' == $input->getType() ? $input->name() : strtoupper($input->name()) + 'a value is required for %s', + $input->getDisplayName() ), $code, $previous); } diff --git a/src/Input/Exceptions/UnrecognisedInputException.php b/src/Input/Exceptions/UnrecognisedInputException.php new file mode 100644 index 0000000..22258f4 --- /dev/null +++ b/src/Input/Exceptions/UnrecognisedInputException.php @@ -0,0 +1,9 @@ +collection->find($name); + if(!($option instanceof Input\AbstractInputType)) { + return null; + } + + return $option; + } + + protected function findArgumentInCollection(int $index, string $token): ?Input\AbstractInputType + { + $arguments = $this->collection->getItemsByType('Argument'); + $position = 0; + foreach($arguments as $a) { + if($position == $index) { + return $a; + } + $position++; + } + return null; + } + protected function parse(): bool { - // So some parsing here. $it = new \ArrayIterator($this->argv); $position = 0; + $argumentCount = 0; while ($it->valid()) { $token = $it->current(); @@ -82,9 +106,9 @@ class Argv extends Input\AbstractInputHandler $value = true; } - $o = $this->collection->findOption($name); + $o = $this->findOptionInCollection($name); - $this->options[ + $this->input[ $o instanceof Input\AbstractInputType ? $o->name() : $name @@ -98,14 +122,14 @@ class Argv extends Input\AbstractInputHandler // Determine if we're expecting a value. // It also might have a long option equivalent, so we need // to look for that too. - $o = $this->collection->findOption($name); + $o = $this->findOptionInCollection($name); // This could also be an incrementing value // and needs to be added up. E.g. e.g. -vvv or -v -v -v // would be -v => 3 if ($o instanceof Input\AbstractInputType && Flags\is_flag_set($o->flags(), Input\AbstractInputType::FLAG_TYPE_INCREMENTING)) { - $value = isset($this->options[$name]) - ? $this->options[$name] + 1 + $value = isset($this->input[$name]) + ? $this->input[$name] + 1 : 1 ; @@ -128,7 +152,7 @@ class Argv extends Input\AbstractInputHandler } } - $this->options[ + $this->input[ $o instanceof Input\AbstractInputType ? $o->name() : $name @@ -141,12 +165,14 @@ class Argv extends Input\AbstractInputHandler // Arguments are positional, so we need to keep a track // of the index and look at the collection for an argument // with the same index - $a = $this->collection->getArgumentsByIndex(count($this->arguments)); - $this->arguments[ + $a = $this->findArgumentInCollection($argumentCount, $token); + + $this->input[ $a instanceof Input\AbstractInputType ? $a->name() - : count($this->arguments) + : $argumentCount ] = $token; + ++$argumentCount; break; } $it->next(); diff --git a/src/Input/InputCollection.php b/src/Input/InputCollection.php index 525ab44..49db36f 100644 --- a/src/Input/InputCollection.php +++ b/src/Input/InputCollection.php @@ -4,116 +4,150 @@ declare(strict_types=1); namespace pointybeard\Helpers\Cli\Input; -class InputCollection +class InputCollection implements \Iterator, \Countable { - private $arguments = []; - private $options = []; + private $items = []; + private $position = 0; + + public const POSITION_APPEND = 0x0001; + public const POSITION_PREPEND = 0x0002; // Prevents the class from being instanciated public function __construct() { + $this->position = 0; } - public function append(Interfaces\InputTypeInterface $input, bool $replace = false): self + public function current(): mixed { - $class = new \ReflectionClass($input); - $this->{'append'.$class->getShortName()}($input, $replace); + return $this->items[$this->position]; + } + + public function key(): scalar + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->items[$this->position]); + } + + public function count() : int { + return count($this->items); + } + + public function exists(string $name, &$index=null): bool { + return (null !== $this->find($name, null, null, $index)); + } + + public function remove(string $name): self { + if(!$this->exists($name, $index)) { + throw new \Exception("Input '{$name}' does not exist in this collection"); + } + unset($this->items[$index]); + return $this; + } + + public function add(Interfaces\InputTypeInterface $input, bool $replace = false, int $position=self::POSITION_APPEND): self + { + if($this->exists($input->name(), $index) && !$replace) { + throw new \Exception( + (new \ReflectionClass($input))->getShortName()." '{$input->name()}' already exists in this collection" + ); + } + + if (true == $replace && null !== $index) { + $this->items[$index] = $input; + } else { + if($position == self::POSITION_PREPEND) { + array_unshift($this->items, $input); + } else { + array_push($this->items, $input); + } + } return $this; } - public function findArgument(string $name, ?int &$index = null): ?AbstractInputType + public function find(string $name, array $restrictToType = null, array $excludeType = null, &$index = null): ?AbstractInputType { - foreach ($this->arguments as $index => $a) { - if ($a->name() == $name) { - return $a; + foreach ($this->items as $index => $input) { + // Check if we're restricting to or excluding specific types + if (null !== $restrictToType && !in_array($input->getType(), $restrictToType)) { + continue; + } elseif (null !== $excludeType && in_array($input->getType(), $excludeType)) { + continue; } + + if ($input->respondsTo($name)) { + return $input; + } + } - $index = null; - return null; } - public function findOption(string $name, ?int &$index = null): ?AbstractInputType + public function getTypes(): array { - $type = 1 == strlen($name) ? 'name' : 'long'; - - foreach ($this->options as $index => $o) { - if ($o->$type() == $name) { - return $o; - } + $types = []; + foreach($this->items as $input) { + $types[] = $input->getType(); } - - $index = null; - - return null; + return array_unique($types); } - private function appendArgument(Interfaces\InputTypeInterface $argument, bool $replace = false): void + public function getItems(): \Iterator { - if (null !== $this->findArgument($argument->name(), $index) && !$replace) { - throw new \Exception("Argument {$argument->name()} already exists in collection"); - } - - if (true == $replace && null !== $index) { - $this->arguments[$index] = $argument; - } else { - $this->arguments[] = $argument; - } + return (new \ArrayObject($this->items))->getIterator(); } - private function appendOption(Interfaces\InputTypeInterface $option, bool $replace = false): void + public function getItemsByType(string $type): \Iterator { - if (null !== $this->findOption($option->name(), $index) && !$replace) { - throw new \Exception("Option -{$option->name()} already exists in collection"); - } - if (true == $replace && null !== $index) { - $this->options[$index] = $option; - } else { - $this->options[] = $option; - } + return new InputTypeFilterIterator( + $this->getItems(), + [$type], + InputTypeFilterIterator::FILTER_INCLUDE + ); } - public function getArgumentsByIndex(int $index): ?AbstractInputType + public function getItemsExcludeByType(string $type): \Iterator { - return $this->arguments[$index]; + return new InputTypeFilterIterator( + $this->getItems(), + [$type], + InputTypeFilterIterator::FILTER_EXCLUDE + ); } - public function getArguments(): array + public function getItemByIndex(int $index): ?AbstractInputType { - return $this->arguments; - } - - public function getOptions(): array - { - return $this->options; + return $this->items[$index] ?? null; } public static function merge(self ...$collections): self { - $arguments = []; - $options = []; + $iterator = new \AppendIterator; foreach ($collections as $c) { - $arguments = array_merge($arguments, $c->getArguments()); - $options = array_merge($options, $c->getOptions()); + $iterator->append($c->getItems()); } $mergedCollection = new self(); - - $it = new \AppendIterator(); - $it->append(new \ArrayIterator($arguments)); - $it->append(new \ArrayIterator($options)); - - foreach ($it as $input) { - try { - $mergedCollection->append($input, true); - } catch (\Exception $ex) { - // Already exists, so skip it. - } + foreach ($iterator as $input) { + $mergedCollection->add($input, true); } - return $mergedCollection; } } diff --git a/src/Input/InputHandlerFactory.php b/src/Input/InputHandlerFactory.php index a899812..2ae425c 100644 --- a/src/Input/InputHandlerFactory.php +++ b/src/Input/InputHandlerFactory.php @@ -9,32 +9,37 @@ use pointybeard\Helpers\Foundation\Factory; final class InputHandlerFactory extends Factory\AbstractFactory { - const FLAG_SKIP_VALIDATION = 0x0001; - - public static function getTemplateNamespace(): string + public function getTemplateNamespace(): string { return __NAMESPACE__.'\\Handlers\\%s'; } - public static function getExpectedClassType(): ?string + public function getExpectedClassType(): ?string { return __NAMESPACE__.'\\Interfaces\\InputHandlerInterface'; } - public static function build(string $name, InputCollection $collection = null, int $flags = null): Interfaces\InputHandlerInterface + public static function build(string $name, ...$arguments): object { + + // Since passing flags is optional, we can use array_pad + // to ensure there are always at least 2 elements in $arguments + [$collection, $flags] = array_pad($arguments, 2, null); + + $factory = new self; + try { - $handler = self::instanciate( - self::generateTargetClassName($name) + $handler = $factory->instanciate( + $factory->generateTargetClassName($name) ); } catch (\Exception $ex) { throw new Exceptions\UnableToLoadInputHandlerException($name, 0, $ex); } - if ($collection instanceof InputCollection) { + if (null !== $collection) { $handler->bind( $collection, - Flags\is_flag_set($flags, self::FLAG_SKIP_VALIDATION) + $flags ); } diff --git a/src/Input/InputTypeFactory.php b/src/Input/InputTypeFactory.php new file mode 100644 index 0000000..82b1115 --- /dev/null +++ b/src/Input/InputTypeFactory.php @@ -0,0 +1,20 @@ +types = array_map('strtolower', $types); + $this->mode = $mode; + + } + public function accept() + { + $input = $this->getInnerIterator()->current(); + + switch($this->mode) { + case self::FILTER_EXCLUDE: + return !in_array($input->getType(), $this->types); + break; + + case self::FILTER_INCLUDE: + default: + return in_array($input->getType(), $this->types); + break; + + } + } +} diff --git a/src/Input/Interfaces/InputHandlerInterface.php b/src/Input/Interfaces/InputHandlerInterface.php index c75ce6b..8345b30 100644 --- a/src/Input/Interfaces/InputHandlerInterface.php +++ b/src/Input/Interfaces/InputHandlerInterface.php @@ -8,19 +8,13 @@ use pointybeard\Helpers\Cli\Input; interface InputHandlerInterface { - public function bind(Input\InputCollection $inputCollection, bool $skipValidation = false): bool; + public function bind(Input\InputCollection $inputCollection, ?int $flags = null): bool; - public function validate(): void; + public function validate(?int $flags = null): void; - public function getArgument(string $name): ?string; + public function find(string $name); - // note that the return value of getOption() isn't always going to be - // a string like getArgument() - public function getOption(string $name); - - public function getArguments(): array; - - public function getOptions(): array; + public function getInput(): array; public function getCollection(): ?Input\InputCollection; } diff --git a/src/Input/Interfaces/InputTypeInterface.php b/src/Input/Interfaces/InputTypeInterface.php index 9d2e396..3a6bc13 100644 --- a/src/Input/Interfaces/InputTypeInterface.php +++ b/src/Input/Interfaces/InputTypeInterface.php @@ -16,4 +16,10 @@ interface InputTypeInterface const FLAG_TYPE_INCREMENTING = 0x0400; public function getType(): string; + + public function respondsTo(string $name): bool; + + public function __toString(): string; + + public function getDisplayName(): string; } diff --git a/src/Input/Types/Argument.php b/src/Input/Types/Argument.php index f303c8d..4550781 100644 --- a/src/Input/Types/Argument.php +++ b/src/Input/Types/Argument.php @@ -6,20 +6,72 @@ namespace pointybeard\Helpers\Cli\Input\Types; use pointybeard\Helpers\Cli\Input; use pointybeard\Helpers\Functions\Strings; +use pointybeard\Helpers\Functions\Cli; class Argument extends Input\AbstractInputType { - public function __toString() + public function __construct(string $name = null, int $flags = null, string $description = null, object $validator = null, $default = null) { - $name = strtoupper($this->name()); - - $first = str_pad(sprintf('%s ', $name), 20, ' '); - - $second = Strings\utf8_wordwrap_array($this->description(), 40); - for ($ii = 1; $ii < count($second); ++$ii) { - $second[$ii] = str_pad('', 22, ' ', \STR_PAD_LEFT).$second[$ii]; + if (null === $validator) { + $validator = function (Input\AbstractInputType $input, Input\AbstractInputHandler $context) { + // This dummy validator is necessary otherwise the argument + // value is ALWAYS set to default (most often NULL) regardless + // of if the argument was set or not + return $context->find($input->name()); + }; } - return $first.implode($second, PHP_EOL); + parent::__construct($name, $flags, $description, $validator, $default); + } + + public function getDisplayName(): string + { + return strtoupper($this->name()); + } + + public function __toString(): string + { + // MAGIC VALUES!!! OH MY..... + $padCharacter = ' '; + $paddingBufferSize = 0.15; // 15% + $argumentNamePaddedWidth = 20; + $argumentNameMinimumPaddingWidth = 4; + $minimumWindowWidth = 80; + + // Get the window dimensions but restrict width to minimum + // of $minimumWindowWidth + $window = Cli\get_window_size(); + $window['cols'] = max($minimumWindowWidth, $window['cols']); + + // This shrinks the total line length (derived by the window width) by + // $paddingBufferSize + $paddingBuffer = (int) ceil($window['cols'] * $paddingBufferSize); + + // Create a string of $padCharacter which is prepended to each secondary + // line + $secondaryLineLeadPadding = str_pad( + '', + $argumentNamePaddedWidth, + $padCharacter, + STR_PAD_LEFT + ); + + $first = Strings\mb_str_pad( + $this->getDisplayName().str_repeat($padCharacter, $argumentNameMinimumPaddingWidth), + $argumentNamePaddedWidth, + $padCharacter + ); + + $second = Strings\utf8_wordwrap_array( + $this->description(), + $window['cols'] - $argumentNamePaddedWidth - $paddingBuffer + ); + + // Skip the first item (notice $ii starts at value of '1') + for ($ii = 1; $ii < count($second); ++$ii) { + $second[$ii] = $secondaryLineLeadPadding.$second[$ii]; + } + + return $first.implode(PHP_EOL, $second); } } diff --git a/src/Input/Types/Flag.php b/src/Input/Types/Flag.php new file mode 100644 index 0000000..a3f2329 --- /dev/null +++ b/src/Input/Types/Flag.php @@ -0,0 +1,18 @@ +short = $short; + parent::__construct($name, $flags, $description, $validator, $default); + } + + public function respondsTo(string $name): bool + { + return $name == $this->name || $name == $this->short; + } + + public function getDisplayName(): string + { + $short = + null !== $this->short() + ? '-'.$this->short().', ' + : null + ; + + return sprintf('%s--%s', $short, $this->name()); + } + + public function __toString(): string + { + // MAGIC VALUES!!! OH MY..... + $padCharacter = ' '; + $paddingBufferSize = 0.15; // 15% + $optionNamePaddedWidth = 30; + $minimumWindowWidth = 80; + $secondaryLineIndentlength = 2; + + // Get the window dimensions but restrict width to minimum + // of $minimumWindowWidth + $window = Cli\get_window_size(); + $window['cols'] = max($minimumWindowWidth, $window['cols']); + + // This shrinks the total line length (derived by the window width) by + // $paddingBufferSize + $paddingBuffer = (int) ceil($window['cols'] * $paddingBufferSize); + + // Create a string of $padCharacter which is prepended to each secondary + // line + $secondaryLineLeadPadding = str_pad( + '', + $optionNamePaddedWidth, + $padCharacter, + STR_PAD_LEFT + ); + + $long = $this->getDisplayName(); + if (Flags\is_flag_set($this->flags(), self::FLAG_VALUE_REQUIRED)) { + $long .= '=VALUE'; + } elseif (Flags\is_flag_set($this->flags(), self::FLAG_VALUE_OPTIONAL)) { + $long .= '[=VALUE]'; + } + + $first = Strings\mb_str_pad( + $long, // -O, --LONG, + $optionNamePaddedWidth, + $padCharacter + ); + + $second = Strings\utf8_wordwrap_array( + $this->description(), + $window['cols'] - $optionNamePaddedWidth - $paddingBuffer + ); + + for ($ii = 1; $ii < count($second); ++$ii) { + $second[$ii] = $secondaryLineLeadPadding.$second[$ii]; + } + + return $first.implode(PHP_EOL, $second); + } +} diff --git a/src/Input/Types/Option.php b/src/Input/Types/Option.php index 3771de1..ab081ab 100644 --- a/src/Input/Types/Option.php +++ b/src/Input/Types/Option.php @@ -4,37 +4,57 @@ declare(strict_types=1); namespace pointybeard\Helpers\Cli\Input\Types; -use pointybeard\Helpers\Functions\Flags; use pointybeard\Helpers\Functions\Strings; +use pointybeard\Helpers\Functions\Cli; use pointybeard\Helpers\Cli\Input; class Option extends Input\AbstractInputType { - protected $long; - protected $default; - - public function __construct(string $name, string $long = null, int $flags = null, string $description = null, object $validator = null, $default = false) + public function getDisplayName(): string { - $this->default = $default; - $this->long = $long; - parent::__construct($name, $flags, $description, $validator); + return '-'.$this->name(); } - public function __toString() + public function __toString(): string { - $long = null !== $this->long() ? ', --'.$this->long() : null; - if (null != $long) { - if (Flags\is_flag_set($this->flags(), self::FLAG_VALUE_REQUIRED)) { - $long .= '=VALUE'; - } elseif (Flags\is_flag_set($this->flags(), self::FLAG_VALUE_OPTIONAL)) { - $long .= '[=VALUE]'; - } - } - $first = str_pad(sprintf('-%s%s ', $this->name(), $long), 36, ' '); + // MAGIC VALUES!!! OH MY..... + $padCharacter = ' '; + $paddingBufferSize = 0.15; // 15% + $optionNamePaddedWidth = 30; + $minimumWindowWidth = 80; + $secondaryLineIndentlength = 2; + + // Get the window dimensions but restrict width to minimum + // of $minimumWindowWidth + $window = Cli\get_window_size(); + $window['cols'] = max($minimumWindowWidth, $window['cols']); + + // This shrinks the total line length (derived by the window width) by + // $paddingBufferSize + $paddingBuffer = (int) ceil($window['cols'] * $paddingBufferSize); + + // Create a string of $padCharacter which is prepended to each secondary + // line + $secondaryLineLeadPadding = str_pad( + '', + $optionNamePaddedWidth, + $padCharacter, + STR_PAD_LEFT + ); + + $first = Strings\mb_str_pad( + $this->getDisplayName(), + $optionNamePaddedWidth, + $padCharacter + ); + + $second = Strings\utf8_wordwrap_array( + $this->description(), + $window['cols'] - $optionNamePaddedWidth - $paddingBuffer + ); - $second = Strings\utf8_wordwrap_array($this->description(), 40); for ($ii = 1; $ii < count($second); ++$ii) { - $second[$ii] = str_pad('', 38, ' ', \STR_PAD_LEFT).$second[$ii]; + $second[$ii] = $secondaryLineLeadPadding.$second[$ii]; } return $first.implode($second, PHP_EOL);