diff --git a/CHANGELOG.md b/CHANGELOG.md index b8055ea2..fb9a34dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Add a option `ignoreValidationErrors` on `AsTask` attribute to ignore parameters & options validation errors +* Add a way to dynamically autocomplete task arguments/options * Add a way to merge an application `box.json` config file used by `castor:repack`command * Deprecate `Context::withPath()` in favor of `Context::withWorkingDirectory()` * Deprecate `path` argument in `capture()`, `exit_code()`, `run()`, `with()` in favor of `workingDirectory` diff --git a/doc/getting-started/installation.md b/doc/getting-started/installation.md index 51cf3e03..45a3f3bc 100644 --- a/doc/getting-started/installation.md +++ b/doc/getting-started/installation.md @@ -165,21 +165,10 @@ jobs: ## Autocomplete -If you use bash, you can enable autocomplete for castor by running the -following task: +Castor provides a built-in autocomplete to ease its usage in shell. -``` -castor completion | sudo tee /etc/bash_completion.d/castor -``` - -Then reload your shell. - -Others shells are also supported (zsh, fish, etc). To get the list of supported -shells and their dedicated instructions, run: - -``` -castor completion --help -``` +See [the dedicated documentation](../going-further/interacting-with-castor/autocomplete.md) +to see how to install it, and also how to autocomplete your arguments. ## Stubs diff --git a/doc/going-further/index.md b/doc/going-further/index.md index e99d8a04..17db1faf 100644 --- a/doc/going-further/index.md +++ b/doc/going-further/index.md @@ -5,7 +5,7 @@ This section contains more advanced topics about Castor. * [Helpers](helpers/console-and-io.md): all builtin features that Castor provides to help you write your tasks. * [Interacting with Castor](interacting-with-castor/advanced-context.md): how to -interact with Castor's output, context, logs, etc. +interact with Castor's output, context, logs, autocomplete, etc. * [Extending Castor](extending-castor/events.md): how to wire some logic inside Castor, how to redistribute your project as your own phar or static binary. diff --git a/doc/going-further/interacting-with-castor/autocomplete.md b/doc/going-further/interacting-with-castor/autocomplete.md new file mode 100644 index 00000000..d9c542f0 --- /dev/null +++ b/doc/going-further/interacting-with-castor/autocomplete.md @@ -0,0 +1,94 @@ +# Autocomplete + +## Installation + +If you use bash, you can enable autocomplete for castor by running the +following task: + +``` +castor completion | sudo tee /etc/bash_completion.d/castor +``` + +Then reload your shell. + +Others shells are also supported (zsh, fish, etc). To get the list of supported +shells and their dedicated instructions, run: + +``` +castor completion --help +``` + +## Autocomplete arguments + +You have to options to make your arguments autocompleted. + +### Static suggestions + +In case your suggestions are fixed, you can pass them in the `suggestedValues` +property of the `AsArgument` and `AsOption` attributes: + +```php +#[AsTask()] +function my_task( + #[AsArgument(name: 'argument', suggestedValues: ['foo', 'bar', 'baz'])] + string $argument, +): void { +} +``` + +When trying to autocomplete the arguments, your shell will now suggest these +values: + +```bash +$ castor my-task [TAB] +bar baz foo +``` + +### Dynamic suggestions + +In case you need some logic to list the suggestions (like suggesting paths or +docker services, making a database query or HTTP request to determine some +values, etc.), you can use the `autocomplete` property of the `AsArgument` and +`AsOption` attributes to provide the function that will return the suggestions: + +```php +namespace example; + +use Symfony\Component\Console\Completion\CompletionInput; + +#[AsTask()] +function autocomplete_argument( + #[AsArgument(name: 'argument', autocomplete: 'example\get_argument_autocompletion')] + string $argument, +): void { +} + +function get_argument_autocompletion(CompletionInput $input): array +{ + // You can search for a file on the filesystem, make a network call, etc. + + return [ + 'foo', + 'bar', + 'baz', + ]; +} +``` + +>[!NOTE] +> Because the syntax `my_callback(...)` is not allowed on attribute, you need to +> specify the `autocomplete` callback with either: +> - the string syntax (`my_namespace\my_function` or `'MyNamespace\MyClass::myFunction'`) +> - the array syntax (`['MyNamespace\MyClass', 'myFunction']`). + +This function receives an optional `Symfony\Component\Console\Completion\CompletionInput` +argument to allow you to pre-filter the suggestions returned to the shell. + +>[!TIP] +> The shell script is able to handle huge amounts of suggestions and will +> automatically filter the suggested values based on the existing input from the +> user. You do not have to implement any filter logic in the function. +> +> You may use CompletionInput::getCompletionValue() to get the current input if +> that helps improving performance (e.g. by reducing the number of rows fetched +> from the database). diff --git a/examples/args.php b/examples/args.php index 088a9f90..6e4ca0e7 100644 --- a/examples/args.php +++ b/examples/args.php @@ -6,6 +6,7 @@ use Castor\Attribute\AsOption; use Castor\Attribute\AsRawTokens; use Castor\Attribute\AsTask; +use Symfony\Component\Console\Completion\CompletionInput; use function Castor\io; @@ -42,3 +43,23 @@ function passthru(#[AsRawTokens] array $rawTokens): void { var_dump($rawTokens); } + +#[AsTask(description: 'Provides autocomplete for an argument')] +function autocomplete_argument( + #[AsArgument(name: 'argument', description: 'This is an argument with autocompletion', autocomplete: 'args\get_argument_autocompletion')] + string $argument, +): void { + var_dump(\func_get_args()); +} + +/** @return string[] */ +function get_argument_autocompletion(CompletionInput $input): array +{ + // You can search for a file on the filesystem, make a network call, etc. + + return [ + 'foo', + 'bar', + 'baz', + ]; +} diff --git a/src/Attribute/AsArgument.php b/src/Attribute/AsArgument.php index 953e3000..c122f040 100644 --- a/src/Attribute/AsArgument.php +++ b/src/Attribute/AsArgument.php @@ -2,16 +2,20 @@ namespace Castor\Attribute; +use Symfony\Component\Console\Completion\CompletionInput; + #[\Attribute(\Attribute::TARGET_PARAMETER)] class AsArgument extends AsCommandArgument { /** - * @param array $suggestedValues + * @param array $suggestedValues + * @param mixed|callable(CompletionInput): array $autocomplete */ public function __construct( ?string $name = null, public readonly string $description = '', public readonly array $suggestedValues = [], + public readonly mixed $autocomplete = null, ) { parent::__construct($name); } diff --git a/src/Attribute/AsOption.php b/src/Attribute/AsOption.php index fa93cfa4..1304853d 100644 --- a/src/Attribute/AsOption.php +++ b/src/Attribute/AsOption.php @@ -2,12 +2,15 @@ namespace Castor\Attribute; +use Symfony\Component\Console\Completion\CompletionInput; + #[\Attribute(\Attribute::TARGET_PARAMETER)] class AsOption extends AsCommandArgument { /** - * @param string|array|null $shortcut - * @param array $suggestedValues + * @param string|array|null $shortcut + * @param array $suggestedValues + * @param mixed|callable(CompletionInput): array $autocomplete */ public function __construct( ?string $name = null, @@ -15,6 +18,7 @@ public function __construct( public readonly ?int $mode = null, public readonly string $description = '', public readonly array $suggestedValues = [], + public readonly mixed $autocomplete = null, ) { parent::__construct($name); } diff --git a/src/Console/Command/TaskCommand.php b/src/Console/Command/TaskCommand.php index 8c9e1a30..5c59c85f 100644 --- a/src/Console/Command/TaskCommand.php +++ b/src/Console/Command/TaskCommand.php @@ -60,7 +60,7 @@ public function getSubscribedSignals(): array return array_keys($this->taskAttribute->onSignals); } - public function handleSignal(int $signal): int|false + public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false { if (!\array_key_exists($signal, $this->taskAttribute->onSignals)) { return false; @@ -117,7 +117,7 @@ protected function configure(): void $mode, $taskArgumentAttribute->description, $parameter->isOptional() ? $parameter->getDefaultValue() : null, - $taskArgumentAttribute->suggestedValues, + $this->getSuggestedValues($taskArgumentAttribute), ); } elseif ($taskArgumentAttribute instanceof AsOption) { if ('verbose' === $name) { @@ -143,7 +143,7 @@ protected function configure(): void $mode, $taskArgumentAttribute->description, $defaultValue, - $taskArgumentAttribute->suggestedValues, + $this->getSuggestedValues($taskArgumentAttribute), ); } } catch (LogicException $e) { @@ -220,4 +220,24 @@ private function getParameterName(\ReflectionParameter $parameter): string { return $this->argumentsMap[$parameter->getName()]; } + + /** + * @return array|\Closure + */ + private function getSuggestedValues(AsArgument|AsOption $attribute): array|\Closure + { + if ($attribute->suggestedValues && null !== $attribute->autocomplete) { + throw new FunctionConfigurationException(sprintf('You cannot define both "suggestedValues" and "autocomplete" option on parameter "%s".', $attribute->name), $this->function); + } + + if (null === $attribute->autocomplete) { + return $attribute->suggestedValues; + } + + if (!\is_callable($attribute->autocomplete)) { + throw new FunctionConfigurationException(sprintf('The value provided in the "autocomplete" option on parameter "%s" is not callable.', $attribute->name), $this->function); + } + + return \Closure::fromCallable($attribute->autocomplete); + } } diff --git a/src/Stub/StubsGenerator.php b/src/Stub/StubsGenerator.php index 2130c322..2711e913 100644 --- a/src/Stub/StubsGenerator.php +++ b/src/Stub/StubsGenerator.php @@ -61,6 +61,7 @@ public function generateStubs(string $dest): void // Add some very frequently used classes $frequentlyUsedClasses = [ \Symfony\Component\Console\Application::class, + \Symfony\Component\Console\Completion\CompletionInput::class, \Symfony\Component\Console\Input\InputArgument::class, \Symfony\Component\Console\Input\InputInterface::class, \Symfony\Component\Console\Input\InputOption::class, diff --git a/tests/AutocompleteTest.php b/tests/AutocompleteTest.php new file mode 100644 index 00000000..b8a6008f --- /dev/null +++ b/tests/AutocompleteTest.php @@ -0,0 +1,73 @@ +createMock(EventDispatcher::class), $this->createMock(ExpressionLanguage::class)); + + $tester = new CommandCompletionTester($command); + $suggestions = $tester->complete([$input]); + + $this->assertSame($expectedValues, $suggestions); + } + + public function getData(): \Generator + { + yield [task_with_suggested_values(...), ['a', 'b', 'c']]; + yield [task_with_autocomplete(...), ['d', 'e', 'f']]; + yield [task_with_autocomplete_filtered(...), ['foo', 'bar', 'baz']]; + yield [task_with_autocomplete_filtered(...), ['bar', 'baz'], 'ba']; + } +} + +function task_with_suggested_values( + #[AsArgument(name: 'argument', suggestedValues: ['a', 'b', 'c'])] + string $argument, +): void { +} + +function task_with_autocomplete( + #[AsArgument(name: 'argument', autocomplete: 'Castor\Tests\complete')] + string $argument, +): void { +} + +/** @return string[] */ +function complete(CompletionInput $input): array +{ + return [ + 'd', + 'e', + 'f', + ]; +} + +function task_with_autocomplete_filtered( + #[AsArgument(name: 'argument', autocomplete: 'Castor\Tests\complete_filtered')] + string $argument, +): void { +} + +/** @return string[] */ +function complete_filtered(CompletionInput $input): array +{ + return array_filter([ + 'foo', + 'bar', + 'baz', + ], fn (string $value) => str_starts_with($value, $input->getCompletionValue())); +} diff --git a/tests/Examples/Generated/ArgsAutocompleteArgumentTest.php b/tests/Examples/Generated/ArgsAutocompleteArgumentTest.php new file mode 100644 index 00000000..b2b600ee --- /dev/null +++ b/tests/Examples/Generated/ArgsAutocompleteArgumentTest.php @@ -0,0 +1,22 @@ +runTask(['args:autocomplete-argument', 'FIXME(argument)']); + + $this->assertSame(0, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); + if (file_exists(__FILE__ . '.err.txt')) { + $this->assertStringEqualsFile(__FILE__ . '.err.txt', $process->getErrorOutput()); + } else { + $this->assertSame('', $process->getErrorOutput()); + } + } +} diff --git a/tests/Examples/Generated/ArgsAutocompleteArgumentTest.php.output.txt b/tests/Examples/Generated/ArgsAutocompleteArgumentTest.php.output.txt new file mode 100644 index 00000000..dca76eab --- /dev/null +++ b/tests/Examples/Generated/ArgsAutocompleteArgumentTest.php.output.txt @@ -0,0 +1,4 @@ +array(1) { + [0]=> + string(15) "FIXME(argument)" +} diff --git a/tests/Examples/Generated/AutocompleteInvalidTest.php b/tests/Examples/Generated/AutocompleteInvalidTest.php new file mode 100644 index 00000000..03b5c992 --- /dev/null +++ b/tests/Examples/Generated/AutocompleteInvalidTest.php @@ -0,0 +1,22 @@ +runTask([], '{{ base }}/tests/Examples/fixtures/broken/autocomplete-invalid'); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); + if (file_exists(__FILE__ . '.err.txt')) { + $this->assertStringEqualsFile(__FILE__ . '.err.txt', $process->getErrorOutput()); + } else { + $this->assertSame('', $process->getErrorOutput()); + } + } +} diff --git a/tests/Examples/Generated/AutocompleteInvalidTest.php.err.txt b/tests/Examples/Generated/AutocompleteInvalidTest.php.err.txt new file mode 100644 index 00000000..737f0c76 --- /dev/null +++ b/tests/Examples/Generated/AutocompleteInvalidTest.php.err.txt @@ -0,0 +1,8 @@ + +In TaskCommand.php line 238: + + Function "autocomplete_argument()" is not properly configured: + The value provided in the "autocomplete" option on parameter "argument" is not callable. + Defined in "castor.php" line 8. + + diff --git a/tests/Examples/Generated/AutocompleteInvalidTest.php.output.txt b/tests/Examples/Generated/AutocompleteInvalidTest.php.output.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/Examples/Generated/ListTest.php.output.txt b/tests/Examples/Generated/ListTest.php.output.txt index 7e5315ea..ac0e2a63 100644 --- a/tests/Examples/Generated/ListTest.php.output.txt +++ b/tests/Examples/Generated/ListTest.php.output.txt @@ -6,6 +6,7 @@ list List commands no-namespace Task without a namespace args:another-args Dumps all arguments and options, without configuration args:args Dumps all arguments and options, with custom configuration +args:autocomplete-argument Provides autocomplete for an argument args:passthru Dumps all arguments and options, without configuration nor validation bar:bar Prints bar, but also executes foo cache:complex Cache with usage of CacheItemInterface diff --git a/tests/Examples/fixtures/broken/autocomplete-invalid/castor.php b/tests/Examples/fixtures/broken/autocomplete-invalid/castor.php new file mode 100644 index 00000000..3d211c8e --- /dev/null +++ b/tests/Examples/fixtures/broken/autocomplete-invalid/castor.php @@ -0,0 +1,21 @@ +