Skip to content

Commit

Permalink
Add a way to dynamically autocomplete task arguments/options
Browse files Browse the repository at this point in the history
  • Loading branch information
pyrech committed Mar 28, 2024
1 parent 89eb467 commit 1a5df73
Show file tree
Hide file tree
Showing 17 changed files with 4,402 additions and 20 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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`
Expand Down
17 changes: 3 additions & 14 deletions doc/getting-started/installation.md
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion doc/going-further/index.md
Expand Up @@ -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.
94 changes: 94 additions & 0 deletions 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).
21 changes: 21 additions & 0 deletions examples/args.php
Expand Up @@ -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;

Expand Down Expand Up @@ -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',
];
}
6 changes: 5 additions & 1 deletion src/Attribute/AsArgument.php
Expand Up @@ -2,16 +2,20 @@

namespace Castor\Attribute;

use Symfony\Component\Console\Completion\CompletionInput;

#[\Attribute(\Attribute::TARGET_PARAMETER)]
class AsArgument extends AsCommandArgument
{
/**
* @param array<string> $suggestedValues
* @param array<string> $suggestedValues
* @param mixed|callable(CompletionInput): array<string> $autocomplete
*/
public function __construct(
?string $name = null,
public readonly string $description = '',
public readonly array $suggestedValues = [],
public readonly mixed $autocomplete = null,
) {
parent::__construct($name);
}
Expand Down
8 changes: 6 additions & 2 deletions src/Attribute/AsOption.php
Expand Up @@ -2,19 +2,23 @@

namespace Castor\Attribute;

use Symfony\Component\Console\Completion\CompletionInput;

#[\Attribute(\Attribute::TARGET_PARAMETER)]
class AsOption extends AsCommandArgument
{
/**
* @param string|array<string>|null $shortcut
* @param array<string> $suggestedValues
* @param string|array<string>|null $shortcut
* @param array<string> $suggestedValues
* @param mixed|callable(CompletionInput): array<string> $autocomplete
*/
public function __construct(
?string $name = null,
public readonly string|array|null $shortcut = null,
public readonly ?int $mode = null,
public readonly string $description = '',
public readonly array $suggestedValues = [],
public readonly mixed $autocomplete = null,
) {
parent::__construct($name);
}
Expand Down
24 changes: 22 additions & 2 deletions src/Console/Command/TaskCommand.php
Expand Up @@ -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) {
Expand All @@ -143,7 +143,7 @@ protected function configure(): void
$mode,
$taskArgumentAttribute->description,
$defaultValue,
$taskArgumentAttribute->suggestedValues,
$this->getSuggestedValues($taskArgumentAttribute),
);
}
} catch (LogicException $e) {
Expand Down Expand Up @@ -220,4 +220,24 @@ private function getParameterName(\ReflectionParameter $parameter): string
{
return $this->argumentsMap[$parameter->getName()];
}

/**
* @return array<string>|\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);
}
}
1 change: 1 addition & 0 deletions src/Stub/StubsGenerator.php
Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions tests/Examples/Generated/ArgsAutocompleteArgumentTest.php
@@ -0,0 +1,22 @@
<?php

namespace Castor\Tests\Examples\Generated;

use Castor\Tests\TaskTestCase;

class ArgsAutocompleteArgumentTest extends TaskTestCase
{
// args:autocomplete-argument
public function test(): void
{
$process = $this->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());
}
}
}
@@ -0,0 +1,4 @@
array(1) {
[0]=>
string(15) "FIXME(argument)"
}
22 changes: 22 additions & 0 deletions tests/Examples/Generated/AutocompleteInvalidTest.php
@@ -0,0 +1,22 @@
<?php

namespace Castor\Tests\Examples\Generated;

use Castor\Tests\TaskTestCase;

class AutocompleteInvalidTest extends TaskTestCase
{
// no task
public function test(): void
{
$process = $this->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());
}
}
}
8 changes: 8 additions & 0 deletions 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.


Empty file.
1 change: 1 addition & 0 deletions tests/Examples/Generated/ListTest.php.output.txt
Expand Up @@ -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
Expand Down

0 comments on commit 1a5df73

Please sign in to comment.