From a6ba3b62afd329e1a4b99261a93a60f86aa4452d Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Fri, 6 Dec 2024 22:06:53 -0800 Subject: [PATCH] feat(console): allow calling console commands via fqcn (#824) --- .../src/Actions/ExecuteConsoleCommand.php | 47 ++++++++--- .../src/Actions/ResolveConsoleCommand.php | 43 ++++++++++ src/Tempest/Console/src/Console.php | 2 +- src/Tempest/Console/src/GenericConsole.php | 4 +- .../Console/src/Input/ConsoleArgumentBag.php | 41 ++++++---- .../Middleware/ResolveOrRescueMiddleware.php | 9 ++- .../Console/src/Testing/ConsoleTester.php | 36 ++------- .../Actions/ExecuteConsoleCommandTest.php | 35 ++++++++ .../Actions/ResolveConsoleCommandTest.php | 79 +++++++++++++++++++ .../Fixtures/CommandWithNonCommandMethods.php | 23 ++++++ .../Middleware/OverviewMiddlewareTest.php | 4 +- tests/bootstrap.php | 1 + 12 files changed, 263 insertions(+), 61 deletions(-) create mode 100644 src/Tempest/Console/src/Actions/ResolveConsoleCommand.php create mode 100644 tests/Integration/Console/Actions/ResolveConsoleCommandTest.php create mode 100644 tests/Integration/Console/Fixtures/CommandWithNonCommandMethods.php diff --git a/src/Tempest/Console/src/Actions/ExecuteConsoleCommand.php b/src/Tempest/Console/src/Actions/ExecuteConsoleCommand.php index 28c0945f1..b08aa979f 100644 --- a/src/Tempest/Console/src/Actions/ExecuteConsoleCommand.php +++ b/src/Tempest/Console/src/Actions/ExecuteConsoleCommand.php @@ -4,6 +4,7 @@ namespace Tempest\Console\Actions; +use Tempest\Console\ConsoleCommand; use Tempest\Console\ConsoleConfig; use Tempest\Console\ConsoleInputBuilder; use Tempest\Console\ConsoleMiddlewareCallable; @@ -11,6 +12,7 @@ use Tempest\Console\Initializers\Invocation; use Tempest\Console\Input\ConsoleArgumentBag; use Tempest\Container\Container; +use Throwable; final readonly class ExecuteConsoleCommand { @@ -18,17 +20,23 @@ public function __construct( private Container $container, private ConsoleConfig $consoleConfig, private ConsoleArgumentBag $argumentBag, + private ResolveConsoleCommand $resolveConsoleCommand, ) { } - public function __invoke(string $commandName): ExitCode|int + public function __invoke(string|array $command, string|array $arguments = []): ExitCode|int { - $callable = $this->getCallable($this->resolveCommandMiddleware($commandName)); + [$commandName, $arguments] = $this->resolveCommandAndArguments($command, $arguments); - $this->argumentBag->setCommandName($commandName); + $consoleCommand = $this->resolveConsoleCommand($command) ?? $this->resolveConsoleCommand($commandName); + $callable = $this->getCallable($consoleCommand?->middleware ?? []); + + $this->argumentBag->setCommandName($consoleCommand?->getName() ?? $commandName); + $this->argumentBag->addMany($arguments); return $callable(new Invocation( argumentBag: $this->argumentBag, + consoleCommand: $consoleCommand, )); } @@ -37,9 +45,7 @@ private function getCallable(array $commandMiddleware): ConsoleMiddlewareCallabl $callable = new ConsoleMiddlewareCallable(function (Invocation $invocation) { $consoleCommand = $invocation->consoleCommand; - $handler = $consoleCommand->handler; - - $consoleCommandClass = $this->container->get($handler->getDeclaringClass()->getName()); + $consoleCommandClass = $this->container->get($consoleCommand->handler->getDeclaringClass()->getName()); $inputBuilder = new ConsoleInputBuilder($consoleCommand, $invocation->argumentBag); @@ -62,10 +68,33 @@ private function getCallable(array $commandMiddleware): ConsoleMiddlewareCallabl return $callable; } - private function resolveCommandMiddleware(string $commandName): array + private function resolveConsoleCommand(string|array $commandName): ?ConsoleCommand { - $consoleCommand = $this->consoleConfig->commands[$commandName] ?? null; + try { + return ($this->resolveConsoleCommand)($commandName); + } catch (Throwable) { + return null; + } + } + + /** @return array{string,array} */ + private function resolveCommandAndArguments(string|array $command, array $arguments = []): array + { + $commandName = $command; + + if (is_array($command)) { + $commandName = $command[0] ?? ''; + } elseif (str_contains($command, ' ')) { + $commandName = explode(' ', $command)[0]; + $arguments = [ + ...(array_slice(explode(' ', trim($command)), offset: 1)), + ...$arguments, + ]; + } - return $consoleCommand->middleware ?? []; + return [ + $commandName, + $arguments, + ]; } } diff --git a/src/Tempest/Console/src/Actions/ResolveConsoleCommand.php b/src/Tempest/Console/src/Actions/ResolveConsoleCommand.php new file mode 100644 index 000000000..6ccc55166 --- /dev/null +++ b/src/Tempest/Console/src/Actions/ResolveConsoleCommand.php @@ -0,0 +1,43 @@ +consoleConfig->commands)) { + return $this->consoleConfig->commands[$command]; + } + + if (is_string($command) && class_exists($command)) { + $command = [$command, '__invoke']; + } + + if (is_array($command)) { + $command = array_find( + array: $this->consoleConfig->commands, + callback: fn (ConsoleCommand $consoleCommand) => + $consoleCommand->handler->getDeclaringClass()->getName() === $command[0] + && $consoleCommand->handler->getName() === $command[1], + ); + + if ($command !== null) { + return $command; + } + } + + throw new Exception('Command not found.'); + } +} diff --git a/src/Tempest/Console/src/Console.php b/src/Tempest/Console/src/Console.php index e82d37c17..99997e0e5 100644 --- a/src/Tempest/Console/src/Console.php +++ b/src/Tempest/Console/src/Console.php @@ -11,7 +11,7 @@ interface Console { - public function call(string $command): ExitCode|int; + public function call(string|array $command, string|array $arguments = []): ExitCode|int; public function readln(): string; diff --git a/src/Tempest/Console/src/GenericConsole.php b/src/Tempest/Console/src/GenericConsole.php index ff96db6b6..81d8aa03e 100644 --- a/src/Tempest/Console/src/GenericConsole.php +++ b/src/Tempest/Console/src/GenericConsole.php @@ -47,9 +47,9 @@ public function __construct( ) { } - public function call(string $command): ExitCode|int + public function call(string|array $command, string|array $arguments = []): ExitCode|int { - return ($this->executeConsoleCommand)($command); + return ($this->executeConsoleCommand)($command, $arguments); } public function setComponentRenderer(InteractiveComponentRenderer $componentRenderer): self diff --git a/src/Tempest/Console/src/Input/ConsoleArgumentBag.php b/src/Tempest/Console/src/Input/ConsoleArgumentBag.php index a29abde15..a20aa7acd 100644 --- a/src/Tempest/Console/src/Input/ConsoleArgumentBag.php +++ b/src/Tempest/Console/src/Input/ConsoleArgumentBag.php @@ -36,22 +36,7 @@ public function __construct(array $arguments) $this->path = [$cli, $commandName]; - foreach ($arguments as $argument) { - if (str_starts_with($argument, '-') && ! str_starts_with($argument, '--')) { - $flags = str_split($argument); - unset($flags[0]); - - foreach ($flags as $flag) { - $arguments[] = "-{$flag}"; - } - } - } - - foreach (array_values($arguments) as $position => $argument) { - $this->add( - ConsoleInputArgument::fromString($argument, $position), - ); - } + $this->addMany($arguments); } /** @@ -159,6 +144,30 @@ public function add(ConsoleInputArgument $argument): self return $this; } + public function addMany(array $arguments): self + { + foreach ($arguments as $argument) { + if (str_starts_with($argument, '-') && ! str_starts_with($argument, '--')) { + $flags = str_split($argument); + unset($flags[0]); + + foreach ($flags as $flag) { + $arguments[] = "-{$flag}"; + } + } + } + + $position = count($this->arguments); + + foreach (array_values($arguments) as $index => $argument) { + $this->add( + ConsoleInputArgument::fromString($argument, $position + $index), + ); + } + + return $this; + } + public function getBinaryPath(): string { return PHP_BINARY; diff --git a/src/Tempest/Console/src/Middleware/ResolveOrRescueMiddleware.php b/src/Tempest/Console/src/Middleware/ResolveOrRescueMiddleware.php index a68916ddf..f38824cad 100644 --- a/src/Tempest/Console/src/Middleware/ResolveOrRescueMiddleware.php +++ b/src/Tempest/Console/src/Middleware/ResolveOrRescueMiddleware.php @@ -5,12 +5,14 @@ namespace Tempest\Console\Middleware; use Tempest\Console\Actions\ExecuteConsoleCommand; +use Tempest\Console\Actions\ResolveConsoleCommand; use Tempest\Console\Console; use Tempest\Console\ConsoleConfig; use Tempest\Console\ConsoleMiddleware; use Tempest\Console\ConsoleMiddlewareCallable; use Tempest\Console\ExitCode; use Tempest\Console\Initializers\Invocation; +use Throwable; final readonly class ResolveOrRescueMiddleware implements ConsoleMiddleware { @@ -18,14 +20,15 @@ public function __construct( private ConsoleConfig $consoleConfig, private Console $console, private ExecuteConsoleCommand $executeConsoleCommand, + private ResolveConsoleCommand $resolveConsoleCommand, ) { } public function __invoke(Invocation $invocation, ConsoleMiddlewareCallable $next): ExitCode|int { - $consoleCommand = $this->consoleConfig->commands[$invocation->argumentBag->getCommandName()] ?? null; - - if (! $consoleCommand) { + try { + $consoleCommand = ($this->resolveConsoleCommand)($invocation->argumentBag->getCommandName()); + } catch (Throwable) { return $this->rescue($invocation->argumentBag->getCommandName()); } diff --git a/src/Tempest/Console/src/Testing/ConsoleTester.php b/src/Tempest/Console/src/Testing/ConsoleTester.php index ede06ad82..65b11ffee 100644 --- a/src/Tempest/Console/src/Testing/ConsoleTester.php +++ b/src/Tempest/Console/src/Testing/ConsoleTester.php @@ -5,13 +5,11 @@ namespace Tempest\Console\Testing; use Closure; -use Exception; use Fiber; use PHPUnit\Framework\Assert; use Tempest\Console\Actions\ExecuteConsoleCommand; use Tempest\Console\Components\InteractiveComponentRenderer; use Tempest\Console\Console; -use Tempest\Console\ConsoleCommand; use Tempest\Console\Exceptions\ConsoleErrorHandler; use Tempest\Console\ExitCode; use Tempest\Console\GenericConsole; @@ -24,7 +22,6 @@ use Tempest\Container\Container; use Tempest\Core\AppConfig; use Tempest\Highlight\Highlighter; -use Tempest\Reflection\MethodReflector; final class ConsoleTester { @@ -43,7 +40,7 @@ public function __construct( ) { } - public function call(string|Closure|array $command): self + public function call(string|Closure|array $command, string|array $arguments = []): self { $clone = clone $this; @@ -82,30 +79,13 @@ public function call(string|Closure|array $command): self $clone->exitCode = $command($console) ?? ExitCode::SUCCESS; }); } else { - if (is_string($command) && class_exists($command)) { - $command = [$command, '__invoke']; - } - - if (is_array($command) || class_exists($command)) { - $handler = MethodReflector::fromParts(...$command); - - $attribute = $handler->getAttribute(ConsoleCommand::class); - - if ($attribute === null) { - throw new Exception("Could not resolve console command from {$command[0]}::{$command[1]}"); - } - - $attribute->setHandler($handler); - - $command = $attribute->getName(); - } - - $fiber = new Fiber(function () use ($command, $clone): void { - $argumentBag = new ConsoleArgumentBag(['tempest', ...explode(' ', $command)]); - - $clone->container->singleton(ConsoleArgumentBag::class, $argumentBag); - - $clone->exitCode = ($this->container->get(ExecuteConsoleCommand::class))($argumentBag->getCommandName()); + $fiber = new Fiber(function () use ($command, $arguments, $clone): void { + $clone->container->singleton(ConsoleArgumentBag::class, new ConsoleArgumentBag(['tempest'])); + $clone->exitCode = $this->container->invoke( + ExecuteConsoleCommand::class, + command: $command, + arguments: $arguments, + ); }); } diff --git a/tests/Integration/Console/Actions/ExecuteConsoleCommandTest.php b/tests/Integration/Console/Actions/ExecuteConsoleCommandTest.php index 23f9d95ba..c6bbe1e97 100644 --- a/tests/Integration/Console/Actions/ExecuteConsoleCommandTest.php +++ b/tests/Integration/Console/Actions/ExecuteConsoleCommandTest.php @@ -4,6 +4,9 @@ namespace Tests\Tempest\Integration\Console\Actions; +use Tempest\Console\GenericConsole; +use Tests\Tempest\Integration\Console\Fixtures\ArrayInputCommand; +use Tests\Tempest\Integration\Console\Fixtures\CommandWithMiddleware; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; /** @@ -18,4 +21,36 @@ public function test_command_specific_middleware(): void ->assertContains('from middleware') ->assertContains('from command'); } + + public function test_command_specific_middleware_through_console(): void + { + $this->console + ->call(fn (GenericConsole $console) => $console->call('with:middleware')) + ->assertContains('from middleware') + ->assertContains('from command'); + } + + public function test_call_command_by_class_name(): void + { + $this->console + ->call(CommandWithMiddleware::class) + ->assertContains('from middleware') + ->assertContains('from command'); + } + + public function test_call_command_by_class_name_with_parameters(): void + { + $this->console + ->call(ArrayInputCommand::class, ['--input=a', '--input=b']) + ->assertSee('["a","b"]'); + } + + public function test_command_with_positional_argument_with_space(): void + { + $this->markTestSkipped('Failing test.'); + + // $this->console + // ->call('complex a "b b" c --flag') + // ->assertSee('ab bc'); + } } diff --git a/tests/Integration/Console/Actions/ResolveConsoleCommandTest.php b/tests/Integration/Console/Actions/ResolveConsoleCommandTest.php new file mode 100644 index 000000000..5f9065178 --- /dev/null +++ b/tests/Integration/Console/Actions/ResolveConsoleCommandTest.php @@ -0,0 +1,79 @@ +container->invoke(ResolveConsoleCommand::class, command: 'array_input'); + + $this->assertInstanceOf(ConsoleCommand::class, $command); + $this->assertSame(ArrayInputCommand::class, $command->handler->getDeclaringClass()->getName()); + $this->assertSame('array_input', $command->getName()); + } + + public function test_resolve_string_with_colon_command(): void + { + $command = $this->container->invoke(ResolveConsoleCommand::class, command: 'with:middleware'); + + $this->assertInstanceOf(ConsoleCommand::class, $command); + $this->assertSame(CommandWithMiddleware::class, $command->handler->getDeclaringClass()->getName()); + $this->assertSame('with:middleware', $command->getName()); + } + + public function test_resolve_fqcn_command(): void + { + $command = $this->container->invoke(ResolveConsoleCommand::class, command: CommandWithMiddleware::class); + + $this->assertInstanceOf(ConsoleCommand::class, $command); + $this->assertSame(CommandWithMiddleware::class, $command->handler->getDeclaringClass()->getName()); + $this->assertSame('with:middleware', $command->getName()); + } + + public function test_resolve_array_command(): void + { + $command = $this->container->invoke(ResolveConsoleCommand::class, command: [CommandWithMiddleware::class, '__invoke']); + + $this->assertInstanceOf(ConsoleCommand::class, $command); + $this->assertSame(CommandWithMiddleware::class, $command->handler->getDeclaringClass()->getName()); + $this->assertSame('with:middleware', $command->getName()); + } + + public function test_resolve_implicit_string_command(): void + { + $command = $this->container->invoke(ResolveConsoleCommand::class, command: 'test:not-empty'); + + $this->assertInstanceOf(ConsoleCommand::class, $command); + $this->assertSame(CommandWithNonCommandMethods::class, $command->handler->getDeclaringClass()->getName()); + $this->assertSame('test:not-empty', $command->getName()); + } + + public function test_resolve_array_with_method_command(): void + { + $command = $this->container->invoke(ResolveConsoleCommand::class, command: [CommandWithNonCommandMethods::class, 'do']); + + $this->assertInstanceOf(ConsoleCommand::class, $command); + $this->assertSame(CommandWithNonCommandMethods::class, $command->handler->getDeclaringClass()->getName()); + $this->assertSame('test:not-empty', $command->getName()); + } + + public function test_non_console_command_throw(): void + { + $this->expectExceptionMessage('Command not found.'); + + $this->container->invoke(ResolveConsoleCommand::class, command: [CommandWithNonCommandMethods::class, 'empty']); + } +} diff --git a/tests/Integration/Console/Fixtures/CommandWithNonCommandMethods.php b/tests/Integration/Console/Fixtures/CommandWithNonCommandMethods.php new file mode 100644 index 000000000..48118f0f0 --- /dev/null +++ b/tests/Integration/Console/Fixtures/CommandWithNonCommandMethods.php @@ -0,0 +1,23 @@ +console - ->call('-a') + ->call('', ['-a']) ->assertContains('hidden'); $this->console - ->call('--all') + ->call('', ['--all']) ->assertContains('hidden'); } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8eebfc0e8..5fbe3ce4b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,3 +5,4 @@ require_once __DIR__ . '/../vendor/autoload.php'; passthru('php tempest discovery:generate'); +echo PHP_EOL;