From d08d1f0fe7834618a8ad533cd6951b6409542d8a Mon Sep 17 00:00:00 2001 From: Dave Mc Nicoll Date: Fri, 19 Sep 2025 13:12:18 +0000 Subject: [PATCH] - Work done on CLI package --- src/CliMiddleware.php | 8 ++- src/CliRequest.php | 80 +++++++++++++++------ src/Command.php | 22 +++++- src/CommandStack.php | 14 ++-- src/Option.php | 109 ++++++++++++++++++++++++++--- src/Option/CountOption.php | 23 ++++++ src/Option/CountOption.php~ | 27 +++++++ src/Option/KeyValueOption.php | 29 ++++++++ src/Option/KeyValueOption.php~ | 25 +++++++ src/Option/MultipleValueOption.php | 23 ++++++ src/Option/ParsedOption.php | 14 ++++ src/Option/ToggleOption.php | 23 ++++++ src/Option/ValueOption.php | 18 +++++ src/OptionStack.php | 92 ++++++++++++++++++++++++ src/OptionValueTypeEnum.php | 13 ++++ 15 files changed, 482 insertions(+), 38 deletions(-) create mode 100644 src/Option/CountOption.php create mode 100644 src/Option/CountOption.php~ create mode 100644 src/Option/KeyValueOption.php create mode 100644 src/Option/KeyValueOption.php~ create mode 100644 src/Option/MultipleValueOption.php create mode 100644 src/Option/ParsedOption.php create mode 100644 src/Option/ToggleOption.php create mode 100644 src/Option/ValueOption.php create mode 100644 src/OptionStack.php create mode 100644 src/OptionValueTypeEnum.php diff --git a/src/CliMiddleware.php b/src/CliMiddleware.php index 2ea7109..2e92f56 100644 --- a/src/CliMiddleware.php +++ b/src/CliMiddleware.php @@ -55,8 +55,10 @@ class CliMiddleware implements MiddlewareInterface $request = $request->withAttribute('cli.command', $command) ->withAttribute('cli.request', $cliRequest); - - return $this->container->make($class)->{$method}($request, []); + + $cliRequest->parseOptions($command); + + return $this->container->make($class)->{$method}($request, $this->commands->arguments, $command->options, $cliRequest->rest); } } else { @@ -79,6 +81,7 @@ class CliMiddleware implements MiddlewareInterface private function printCommand(Command $command) : ResponseInterface { + # @TODO ! $text = [ $command->description, "", "#[color: rgb(145;130;195), format: bold]Usage:[#]", @@ -93,6 +96,7 @@ class CliMiddleware implements MiddlewareInterface $text = ( new AsciiFormatter() )->parse($text); } + # @TODO => plug httpfactory here ! return new TextResponse( implode(PHP_EOL, $text) ); } } diff --git a/src/CliRequest.php b/src/CliRequest.php index ef84475..f80b44d 100644 --- a/src/CliRequest.php +++ b/src/CliRequest.php @@ -10,9 +10,9 @@ class CliRequest public array $options = []; - protected string $shortOptions; + public mixed $rest = ""; - protected array $longOptions; + protected array $unparsedOptions; public function __construct( public ServerRequestInterface $request @@ -20,22 +20,6 @@ class CliRequest $this->parseCommandLine(); } - public function matchOptions(string ... $options) : bool - { - foreach($options as $opt) { - if ( $this->matchOption($opt) ) { - return true; - } - } - - return false; - } - - public function matchOption(string $option) : bool - { - return true; - } - protected function parseCommandLine() : void { $executionString = $this->argv(); @@ -43,11 +27,67 @@ class CliRequest # Removing execution's path array_shift($executionString); - foreach($executionString as $cmd) { + while ( $cmd = array_shift($executionString) ) { + if (str_starts_with($cmd, '-')) { + array_unshift($executionString, $cmd); + break; + } + $this->command[] = $cmd; } - $this->command = $executionString; + $this->unparsedOptions = $executionString; + } + + /** + * Replaces the limited getopt() method which do not allow for parameters to be passed after multiple command arguments + * + * @param Command $command Matched command found using parseCommandLine + * @return void + */ + public function parseOptions(Command $command) : void + { + $this->getopt($command->options); + +/* + if ( false !== $options ) { + if ($restIndex !== null) { + $this->rest = $this->argv()[$restIndex]; + } + + $this->options = $options; + }*/ + } + + protected function getopt(OptionStack $options) :void + { + $currentOption = false; + + while ( $argument = array_shift($this->unparsedOptions) ) { + #if ($currentOption !== false) { + $currentOption = $options->matchOption($argument); + + if ($currentOption === false) { + $this->rest = implode(' ', $this->unparsedOptions); + + return; + } + + /*elseif (is_array($currentOption)) { + foreach($currentOption as $matchingOption) { + $matchingOption->handle(); + } + } + else { + + }*/ + # } + #else { + + # } + } + + # dump($options); } protected function argv() : array diff --git a/src/Command.php b/src/Command.php index 3f4905f..4b5f6d6 100644 --- a/src/Command.php +++ b/src/Command.php @@ -4,17 +4,33 @@ namespace Mcnd\CLI; class Command { + public array $arguments; + + public readonly OptionStack $options; + public function __construct( public readonly string $name, public string $description, - public array $options = [], + array $options = [], public null|Command $parent = null, public \Closure|string|null $callback = null, public bool $isRoot = false, - ) {} + ) { + $this->options = new OptionStack($options); + } public function pushOption(Option|array $option) : void { - $this->options[] = array_merge($this->options, $option); + if (is_array($option)) { + $this->options->merge($option); + } + else { + $this->options->append($option); + } + } + + public function setArguments(array $arguments) : void + { + $this->arguments = $arguments; } } \ No newline at end of file diff --git a/src/CommandStack.php b/src/CommandStack.php index 4bfb14b..56bb5fd 100644 --- a/src/CommandStack.php +++ b/src/CommandStack.php @@ -4,10 +4,10 @@ namespace Mcnd\CLI; class CommandStack { - public array $commands = []; - - public function __construct() - {} + public function __construct( + public array $commands = [], + public array $arguments = [] + ) {} public function merge(CommandStack $stack) : void { @@ -28,16 +28,20 @@ class CommandStack */ public function matchCommand(array $commands) : null|Command { + $arguments = []; + while ($commands) { $cmd = implode(' ', $commands); foreach ($this->commands as $command) { if ($command->name === $cmd) { + $this->arguments = array_reverse($arguments); + return $command; } } - array_pop($commands); + $arguments[] = array_pop($commands); } return null; diff --git a/src/Option.php b/src/Option.php index 7020b3d..6913a4f 100644 --- a/src/Option.php +++ b/src/Option.php @@ -2,20 +2,113 @@ namespace Mcnd\CLI; +use Mcnd\CLI\Option\{CountOption, ParsedOption, ToggleOption, ValueOption, KeyValueOption}; + class Option { - public const ARGUMENT_NONE = 0; - - public const ARGUMENT_OPTIONAL = 1; - - public const ARGUMENT_REQUIRED = 2; + protected ParsedOption $parsed; public function __construct( - public array|string $toggle, + public array|string $switch, public string $description = "", - public int $argument = self::ARGUMENT_NONE, public mixed $default = null, public mixed $awaiting = null, - ) {} + public OptionValueTypeEnum $value = OptionValueTypeEnum::NoValue, + ) { + $this->setParsedOption(); + } + public function getParsedOption() : ParsedOption + { + return $this->parsed; + } + + public function setParsedOption() : void + { + if ($this->value === OptionValueTypeEnum::NoValue) { + $this->parsed = new ToggleOption($this); + } + elseif ($this->value === OptionValueTypeEnum::OptionalValue || $this->value === OptionValueTypeEnum::RequiredValue ) { + $this->parsed = new ValueOption($this); + } + elseif ($this->value === OptionValueTypeEnum::KeyValue) { + $this->parsed = new KeyValueOption($this); + } + elseif ($this->value === OptionValueTypeEnum::CountValue) { + $this->parsed = new CountOption($this); + } + } + + public function awaitingValue() : bool + { + return match($this->value) { + OptionValueTypeEnum::NoValue => false, + OptionValueTypeEnum::CountValue => false, + OptionValueTypeEnum::RequiredValue => $this->parsed->getValue() === null, + OptionValueTypeEnum::OptionalValue => $this->parsed->getValue() === null, + OptionValueTypeEnum::MultipleValue => true, + OptionValueTypeEnum::KeyValue => true, + }; + } + + public function matchShortSwitch(string $switch) : bool + { + $switch = strtolower($switch); + + foreach((array) $this->switch as $item) { + if ( ! str_starts_with($item, '--') && substr($item, 0, 1) === '-' ) { + $singleItem = substr($item, 1, 1); + + if (substr($item, 1, 1) === $switch) { + return true; + } + } + } + + return false; + } + + public function matchLongSwitch(string $switch) : bool + { + list($key, $value) = array_pad(explode('=', $switch, 2), 2, null); + + foreach((array) $this->switch as $item) { + if ( str_starts_with($item, '--') ) { + if (strtolower(substr($item, 2)) === strtolower($key)) { + if ($value !== null) { + $this->value($value); + } + + return true; + } + } + } + + return false; + } + + public function toggle() : void + { + match ($this->value) { + OptionValueTypeEnum::NoValue => $this->parsed->toggle(), + OptionValueTypeEnum::CountValue => $this->parsed->toggle(), + OptionValueTypeEnum::OptionalValue => $this->parsed->setValue(true), + }; + } + + public function value(mixed $value = null) : mixed + { + if ($value === null) { + return $this->parsed->getValue(); + } + + return match ($this->value) { + OptionValueTypeEnum::MultipleValue => $this->parsed->addValue($value), + OptionValueTypeEnum::RequiredValue => $this->parsed->setValue($value), + OptionValueTypeEnum::OptionalValue => $this->parsed->setValue($value), + OptionValueTypeEnum::KeyValue => $this->parsed->setRawValue($value), + OptionValueTypeEnum::NoValue => throw new \RuntimeException(sprintf("A value was given to an option (%s) which was not awaiting any", implode(', ', (array) $this->switch))), + OptionValueTypeEnum::CountValue => is_numeric($value) ? $this->parsed->setValue($value) : throw new \RuntimeException(sprintf("A value was given to an option (%s) which is countable", implode(', ', (array) $this->switch))), + }; + } } \ No newline at end of file diff --git a/src/Option/CountOption.php b/src/Option/CountOption.php new file mode 100644 index 0000000..23691d4 --- /dev/null +++ b/src/Option/CountOption.php @@ -0,0 +1,23 @@ +count++; + } + + public function getValue() : null|int + { + return $this->count ?: null; + } + + public function setValue(int $value) : void + { + $this->count = $value; + } +} \ No newline at end of file diff --git a/src/Option/CountOption.php~ b/src/Option/CountOption.php~ new file mode 100644 index 0000000..eabb989 --- /dev/null +++ b/src/Option/CountOption.php~ @@ -0,0 +1,27 @@ +count++; + } + + public function getValue() : null|int + { + return $this->count ?: null; + } + + public function setValue(mixed $value) : void + { + if ( ! is_int($value) ) { + throw new \RuntimeException("A value was given to an option (%s) which is countable"); + } + + $this->count = $value; + } +} \ No newline at end of file diff --git a/src/Option/KeyValueOption.php b/src/Option/KeyValueOption.php new file mode 100644 index 0000000..530e435 --- /dev/null +++ b/src/Option/KeyValueOption.php @@ -0,0 +1,29 @@ +values[$key] = $value; + } + + public function setRawValue(string $value) : void + { + if (! str_contains($value, '=')) { + throw new \RuntimeException(sprintf("A key=value notation was awaited, received %s instead", $value)); + } + + list($key, $value) = explode('=', trim($value), 2); + + $this->setValue($key, $value); + } + + public function getValue() : null|array + { + return $this->values ?: null; + } +} \ No newline at end of file diff --git a/src/Option/KeyValueOption.php~ b/src/Option/KeyValueOption.php~ new file mode 100644 index 0000000..ede9862 --- /dev/null +++ b/src/Option/KeyValueOption.php~ @@ -0,0 +1,25 @@ +values[$key] = $value; + } + + public function setRawValue(string $value) : void + { + list($key, $value) = explode('=', trim($value), 2); + + $this->setValue($key, $value); + } + + public function getValue() : null|array + { + return $this->values ?: null; + } +} \ No newline at end of file diff --git a/src/Option/MultipleValueOption.php b/src/Option/MultipleValueOption.php new file mode 100644 index 0000000..a2f0b58 --- /dev/null +++ b/src/Option/MultipleValueOption.php @@ -0,0 +1,23 @@ +value[] = $value; + } + + public function setValue(array $value) : void + { + $this->value = $value; + } + + public function getValue() : null|array + { + return $this->value ?? null; + } +} \ No newline at end of file diff --git a/src/Option/ParsedOption.php b/src/Option/ParsedOption.php new file mode 100644 index 0000000..8ea1d07 --- /dev/null +++ b/src/Option/ParsedOption.php @@ -0,0 +1,14 @@ +value = true; + } + + public function getValue() : null|true + { + return $this->value ?: null; + } + + public function toggled() : bool + { + return (bool) $this->getValue(); + } +} \ No newline at end of file diff --git a/src/Option/ValueOption.php b/src/Option/ValueOption.php new file mode 100644 index 0000000..1c65636 --- /dev/null +++ b/src/Option/ValueOption.php @@ -0,0 +1,18 @@ +value = $value; + } + + public function getValue() : null|string + { + return $this->value ?? null; + } +} \ No newline at end of file diff --git a/src/OptionStack.php b/src/OptionStack.php new file mode 100644 index 0000000..8056956 --- /dev/null +++ b/src/OptionStack.php @@ -0,0 +1,92 @@ +options = array_merge($this->options, is_array($stack) ? $stack : $stack->options); + } + + public function append(Option $option) : void + { + $this->options[] = $option; + } + + public function get(string $switch) : Option|false + { + foreach($this->options as $option) { + if ($option->matchShortSwitch($switch) || $option->matchLongSwitch($switch)) { + return $option; + } + } + + return false; + } + + + /** + * Matches the exact option (short or long switch) found from the stack + * + * @param string $switch + * @return Option|null + */ + public function matchOption(string $switch) : bool|Option + { + $matched = false; + + # Long switch + if (str_starts_with($switch, '--')) { + $switch = substr($switch, 2); + + foreach($this->options as $option) { + if ( $option->matchLongSwitch($switch) ) { + $matched = true; + + if ( $option->awaitingValue() ) { + return $option; + } + # matching --switch + else { + $option->toggle(); + } + } + } + } # Short switch + elseif (substr($switch, 0, 1) === '-') { + $switch = substr($switch, 1); + $split = str_split($switch); + + foreach($split as $index => $singleSwitch) { + foreach ($this->options as $option) { + if ( $option->matchShortSwitch($singleSwitch) ) { + $matched = true; + + if ( $option->awaitingValue() ) { + # matching -abcValueOfC + if ( $index < count($split) - 1 ) { + $option->value(substr($switch, $index + 1)); + + return false; + } + # matching -abc + else { + return $option; + } + } + else { + $option->toggle(); + } + } + } + } + } + + return $matched; + } +} \ No newline at end of file diff --git a/src/OptionValueTypeEnum.php b/src/OptionValueTypeEnum.php new file mode 100644 index 0000000..5114817 --- /dev/null +++ b/src/OptionValueTypeEnum.php @@ -0,0 +1,13 @@ +