diff --git a/app/Console/FailCommand.php b/app/Console/FailCommand.php index cba9628..4b8ffc4 100644 --- a/app/Console/FailCommand.php +++ b/app/Console/FailCommand.php @@ -10,7 +10,7 @@ final readonly class FailCommand { #[ConsoleCommand('fail')] - public function __invoke(string $input) + public function __invoke(string $input = 'default') { failingFunction($input); } diff --git a/app/Console/Hello.php b/app/Console/Hello.php index 3bd85e4..78789b1 100644 --- a/app/Console/Hello.php +++ b/app/Console/Hello.php @@ -4,6 +4,7 @@ namespace App\Console; +use Tempest\Console\ConsoleArgument; use Tempest\Console\ConsoleCommand; use Tempest\Console\ConsoleOutput; @@ -22,9 +23,17 @@ public function world(string $input) $this->output->error($input); } - #[ConsoleCommand] - public function test(?int $optionalValue = null, bool $flag = false) - { + #[ConsoleCommand( + aliases: ['t'] + )] + public function test( + #[ConsoleArgument( + help: 'The path to the file', + aliases: ['ov'] + )] + ?int $optionalValue = null, + bool $flag = false + ) { $value = $optionalValue ?? 'null'; $this->output->info("{$value}"); diff --git a/src/Actions/RenderConsoleCommand.php b/src/Actions/RenderConsoleCommand.php index 1bf2734..2e99f75 100644 --- a/src/Actions/RenderConsoleCommand.php +++ b/src/Actions/RenderConsoleCommand.php @@ -23,8 +23,8 @@ public function __invoke(ConsoleCommand $consoleCommand): void $parts[] = $this->renderParameter($parameter); } - if ($consoleCommand->getDescription() !== null && $consoleCommand->getDescription() !== '') { - $parts[] = "- {$consoleCommand->getDescription()}"; + if ($consoleCommand->description !== null && $consoleCommand->description !== '') { + $parts[] = "- {$consoleCommand->description}"; } $this->output->writeln(' ' . implode(' ', $parts)); diff --git a/src/ConsoleApplication.php b/src/ConsoleApplication.php index ee8a26d..12c5f4d 100644 --- a/src/ConsoleApplication.php +++ b/src/ConsoleApplication.php @@ -5,7 +5,6 @@ namespace Tempest\Console; use ArgumentCountError; -use ReflectionMethod; use Tempest\AppConfig; use Tempest\Application; use Tempest\Console\Actions\RenderConsoleCommandOverview; @@ -32,7 +31,7 @@ public static function boot( $container = $kernel->init(); $application = new self( - args: $_SERVER['argv'], + argumentBag: new ConsoleArgumentBag($_SERVER['argv']), container: $container, appConfig: $appConfig, ); @@ -48,7 +47,7 @@ public static function boot( } public function __construct( - private array $args, + private ConsoleArgumentBag $argumentBag, private Container $container, private AppConfig $appConfig, ) { @@ -57,7 +56,7 @@ public function __construct( public function run(): void { try { - $commandName = $this->args[1] ?? null; + $commandName = $this->argumentBag->getCommandName(); if (! $commandName) { $this->container->get(RenderConsoleCommandOverview::class)(); @@ -94,40 +93,30 @@ private function handleCommand(string $commandName): void $handler = $consoleCommand->handler; - $params = $this->resolveParameters($handler); - $commandClass = $this->container->get($handler->getDeclaringClass()->getName()); try { - $handler->invoke($commandClass, ...$params); + $handler->invoke( + $commandClass, + ...$this->buildInput($consoleCommand), + ); } catch (ArgumentCountError) { throw new InvalidCommandException($commandName, $consoleCommand); } } - private function resolveParameters(ReflectionMethod $handler): array + /** + * Returns resolved key-value pair of parameters. + * + * @return array + */ + private function buildInput(ConsoleCommand $command): array { - $parameters = $handler->getParameters(); - $inputArguments = $this->args; - unset($inputArguments[0], $inputArguments[1]); - $inputArguments = array_values($inputArguments); - - $result = []; - - foreach ($inputArguments as $i => $argument) { - if (str_starts_with($argument, '--')) { - $parts = explode('=', str_replace('--', '', $argument)); - - $key = $parts[0]; - - $result[$key] = $parts[1] ?? true; - } else { - $key = ($parameters[$i] ?? null)?->getName(); - - $result[$key ?? $i] = $argument; - } - } + $builder = new ConsoleInputBuilder( + $command->getDefinition(), + $this->argumentBag, + ); - return $result; + return $builder->build(); } } diff --git a/src/ConsoleArgument.php b/src/ConsoleArgument.php new file mode 100644 index 0000000..d8b264e --- /dev/null +++ b/src/ConsoleArgument.php @@ -0,0 +1,18 @@ + $arguments + */ + public function __construct(array $arguments) + { + $this->path = array_filter([ + $arguments[0] ?? null, + $arguments[1] ?? null, + ]); + + unset($arguments[0], $arguments[1]); + + foreach (array_values($arguments) as $position => $argument) { + $this->add( + ConsoleInputArgument::fromString($argument, $position) + ); + } + } + + /** + * @return ConsoleInputArgument[] + */ + public function all(): array + { + return $this->arguments; + } + + private function add(ConsoleInputArgument $argument): self + { + $this->arguments[] = $argument; + + return $this; + } + + public function getCommandName(): string + { + return $this->path[1] ?? ''; + } +} diff --git a/src/ConsoleArgumentDefinition.php b/src/ConsoleArgumentDefinition.php new file mode 100644 index 0000000..db33885 --- /dev/null +++ b/src/ConsoleArgumentDefinition.php @@ -0,0 +1,60 @@ +getAttributes(ConsoleArgument::class); + + /** @var ?ConsoleArgument $attribute */ + $attribute = ($attributes[0] ?? null)?->newInstance(); + + return new ConsoleArgumentDefinition( + name: $parameter->getName(), + type: $parameter->getType()->getName(), + default: $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null, + hasDefault: $parameter->isDefaultValueAvailable(), + position: $parameter->getPosition(), + description: $attribute?->description, + aliases: $attribute->aliases ?? [], + help: $attribute?->help, + ); + } + + public function matchesArgument(ConsoleInputArgument $argument): bool + { + if ($argument->position === $this->position) { + return true; + } + + if (! $argument->name) { + return false; + } + + foreach ([$argument->name, ...$this->aliases] as $alias) { + if ($alias === $argument->name) { + return true; + } + + return false; + } + } +} diff --git a/src/ConsoleCommand.php b/src/ConsoleCommand.php index 3882b77..51e1091 100644 --- a/src/ConsoleCommand.php +++ b/src/ConsoleCommand.php @@ -6,16 +6,25 @@ use Attribute; use ReflectionMethod; +use Tempest\Support\ArrayHelper; #[Attribute] final class ConsoleCommand { public ReflectionMethod $handler; + /** @var string[] */ + public readonly array $help; + public function __construct( private readonly ?string $name = null, - private readonly ?string $description = null, + public readonly ?string $description = null, + /** @var string[] */ + public readonly array $aliases = [], + /** @var string|string[] $help */ + string|array $help = [], ) { + $this->help = ArrayHelper::wrap($help); } public function setHandler(ReflectionMethod $handler): self @@ -36,11 +45,6 @@ public function getName(): string : strtolower($this->handler->getDeclaringClass()->getShortName() . ':' . $this->handler->getName()); } - public function getDescription(): ?string - { - return $this->description; - } - public function __serialize(): array { return [ @@ -48,6 +52,8 @@ public function __serialize(): array 'description' => $this->description, 'handler_class' => $this->handler->getDeclaringClass()->getName(), 'handler_method' => $this->handler->getName(), + 'aliases' => $this->aliases, + 'help' => $this->help, ]; } @@ -59,5 +65,18 @@ public function __unserialize(array $data): void objectOrMethod: $data['handler_class'], method: $data['handler_method'], ); + $this->aliases = $data['aliases']; + $this->help = $data['help']; + } + + public function getDefinition(): ConsoleCommandDefinition + { + $arguments = []; + + foreach ($this->handler->getParameters() as $parameter) { + $arguments[$parameter->getName()] = ConsoleArgumentDefinition::fromParameter($parameter); + } + + return new ConsoleCommandDefinition($arguments); } } diff --git a/src/ConsoleCommandDefinition.php b/src/ConsoleCommandDefinition.php new file mode 100644 index 0000000..a364bbe --- /dev/null +++ b/src/ConsoleCommandDefinition.php @@ -0,0 +1,15 @@ +commands[$consoleCommand->getName()] = $consoleCommand; + foreach ($consoleCommand->aliases as $alias) { + if (array_key_exists($alias, $this->commands)) { + continue; + } + + $this->commands[$alias] = $consoleCommand; + } + return $this; } } diff --git a/src/ConsoleInputArgument.php b/src/ConsoleInputArgument.php new file mode 100644 index 0000000..164ee35 --- /dev/null +++ b/src/ConsoleInputArgument.php @@ -0,0 +1,56 @@ + true, + 'false' => false, + default => $value, + }; + + return [$key, $value]; + } +} diff --git a/src/ConsoleInputBuilder.php b/src/ConsoleInputBuilder.php new file mode 100644 index 0000000..625416a --- /dev/null +++ b/src/ConsoleInputBuilder.php @@ -0,0 +1,84 @@ + + * @throws UnresolvedArgumentsException + */ + public function build(): array + { + $validArguments = []; + + $passedArguments = $this->argumentBag->all(); + $argumentDefinitionList = $this->commandDefinition->argumentDefinitionList; + + if (! $argumentDefinitionList && $passedArguments) { + throw UnresolvedArgumentsException::fromArguments($passedArguments); + } + + foreach ($argumentDefinitionList as $definitionKey => $definitionArgument) { + $validArguments[] = $this->resolveArgument($definitionArgument, $passedArguments); + unset($argumentDefinitionList[$definitionKey]); + } + + if (count($passedArguments) > 0 || count($argumentDefinitionList) > 0) { + throw UnresolvedArgumentsException::fromArguments([ + ...$passedArguments, + ...$argumentDefinitionList, + ]); + } + + return $this->toValues($validArguments); + } + + /** + * @param ConsoleInputArgument[] $validArguments + * + * @return array + */ + private function toValues(array $validArguments): array + { + return array_map( + callback: fn (ConsoleInputArgument $argument) => $argument->value, + array: $validArguments + ); + } + + private function resolveArgument(ConsoleArgumentDefinition $argumentDefinition, array &$passedArguments): ?ConsoleInputArgument + { + foreach ($passedArguments as $key => $argument) { + if ($argumentDefinition->matchesArgument($argument)) { + unset($passedArguments[$key]); + return $argument; + } + } + + /** + * In case there was no passed argument that matches this definition argument, + * we'll check if the definition argument has a default value. + */ + if (! $argumentDefinition->hasDefault) { + return null; + } + + return new ConsoleInputArgument( + name: $argumentDefinition->name, + value: $argumentDefinition->default, + position: $argumentDefinition->position, + ); + } +} diff --git a/src/Exceptions/UnresolvedArgumentsException.php b/src/Exceptions/UnresolvedArgumentsException.php new file mode 100644 index 0000000..6328628 --- /dev/null +++ b/src/Exceptions/UnresolvedArgumentsException.php @@ -0,0 +1,61 @@ +setArguments(array_values($arguments)); + + return $exception; + } + + /** + * @param ConsoleInputArgument[] $arguments + * + * @return $this + */ + public function setArguments(array $arguments): self + { + $this->arguments = $arguments; + + return $this; + } + + /** + * @return ConsoleInputArgument[] + */ + public function getArguments(): array + { + return $this->arguments; + } + + public function render(ConsoleOutput $output): void + { + $output->error('Unresolved arguments found'); + + foreach ($this->arguments as $argument) { + $output->writeln( + sprintf( + 'Argument %s is invalid', + $argument->name ?? "#$argument->position", + ), + ); + } + } +} diff --git a/src/Testing/Console/ConsoleCommandTester.php b/src/Testing/Console/ConsoleCommandTester.php index 3ec6479..8f72b39 100644 --- a/src/Testing/Console/ConsoleCommandTester.php +++ b/src/Testing/Console/ConsoleCommandTester.php @@ -6,6 +6,7 @@ use Tempest\AppConfig; use Tempest\Console\ConsoleApplication; +use Tempest\Console\ConsoleArgumentBag; use Tempest\Console\ConsoleOutput; use Tempest\Console\Exceptions\ConsoleExceptionHandler; use Tempest\Container\Container; @@ -27,7 +28,7 @@ public function call(string $command): TestConsoleHelper $appConfig->exceptionHandlers[] = $this->container->get(ConsoleExceptionHandler::class); $application = new ConsoleApplication( - args: ['tempest', ...explode(' ', $command)], + argumentBag: new ConsoleArgumentBag(['tempest', ...explode(' ', $command)]), container: $this->container, appConfig: $appConfig, ); diff --git a/tests/Unit/Console/ArgumentBagTest.php b/tests/Unit/Console/ArgumentBagTest.php new file mode 100644 index 0000000..3337979 --- /dev/null +++ b/tests/Unit/Console/ArgumentBagTest.php @@ -0,0 +1,50 @@ +assertCount(3, $bag->all()); + + $firstArg = $bag->all()[0]; + $this->assertSame('value', $firstArg->value); + $this->assertSame(0, $firstArg->position); + $this->assertNull($firstArg->name); + + $forceFlag = $bag->all()[1]; + $this->assertSame(true, $forceFlag->value); + $this->assertSame(1, $forceFlag->position); + $this->assertSame('force', $forceFlag->name); + + $timesFlag = $bag->all()[2]; + $this->assertSame('2', $timesFlag->value); + $this->assertSame(2, $timesFlag->position); + $this->assertSame('times', $timesFlag->name); + + $this->assertSame( + 'hello:world', + $bag->getCommandName(), + ); + } +} diff --git a/tests/Unit/Console/CommandInputBuilderTest.php b/tests/Unit/Console/CommandInputBuilderTest.php new file mode 100644 index 0000000..5f7767a --- /dev/null +++ b/tests/Unit/Console/CommandInputBuilderTest.php @@ -0,0 +1,92 @@ +build(); + + $this->assertSame( + ['value', true], + $output + ); + } + + public function test_flags_are_casted_to_bool() + { + $builder = new ConsoleInputBuilder( + new ConsoleCommandDefinition([ + new ConsoleArgumentDefinition('force', 'bool', false, hasDefault: false, position: 0), + ]), + new ConsoleArgumentBag(['tempest', 'some:command', '--force']) + ); + + $output = $builder->build(); + + $this->assertSame( + [true], + $output + ); + } + + public function test_argument_cannot_be_resolved_twice() + { + $builder = new ConsoleInputBuilder( + new ConsoleCommandDefinition([ + new ConsoleArgumentDefinition('name', 'string', null, hasDefault: false, position: 0), + ]), + new ConsoleArgumentBag(['tempest', 'some:command', 'value', '--name=other']) + ); + + try { + $builder->build(); + + $this->fail('Expected exception to be thrown'); + } catch (UnresolvedArgumentsException $e) { + $this->assertCount(1, $e->getArguments()); + $this->assertSame('name', $e->getArguments()[0]->name); + } + } + + public function test_both_named_and_positional_arguments_can_be_used() + { + $builder = new ConsoleInputBuilder( + new ConsoleCommandDefinition([ + new ConsoleArgumentDefinition('name', 'string', null, hasDefault: false, position: 0), + new ConsoleArgumentDefinition('test', 'string', null, hasDefault: false, position: 1), + new ConsoleArgumentDefinition('foo', 'string', null, hasDefault: false, position: 2), + ]), + new ConsoleArgumentBag(['tempest', 'some:command', 'value', '--test=other', 'bar']) + ); + + $output = $builder->build(); + + $this->assertSame( + ['value', 'other', 'bar'], + $output + ); + } +} diff --git a/tests/Unit/Console/Fixtures/CommandAliasesWork.php b/tests/Unit/Console/Fixtures/CommandAliasesWork.php new file mode 100644 index 0000000..f882c0f --- /dev/null +++ b/tests/Unit/Console/Fixtures/CommandAliasesWork.php @@ -0,0 +1,41 @@ +getAttributes(ConsoleCommand::class)[0]->newInstance(); + + $consoleCommand->setHandler($handler); + + $string = str_replace( + [ + ConsoleStyle::FG_BLUE->value, + ConsoleStyle::FG_DARK_BLUE->value, + ConsoleStyle::RESET->value, + ConsoleStyle::ESC->value, + ], + '', + (new RenderConsoleCommand())($consoleCommand) + ); + + $this->assertSame( + 'frameworks:list [sortByBest=false] - List all available frameworks.', + $string + ); + } +} diff --git a/tests/Unit/Console/Fixtures/ListFrameworks.php b/tests/Unit/Console/Fixtures/ListFrameworks.php new file mode 100644 index 0000000..08d9918 --- /dev/null +++ b/tests/Unit/Console/Fixtures/ListFrameworks.php @@ -0,0 +1,20 @@ +