Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a way to dynamically autocomplete task arguments/options #343

Merged
merged 2 commits into from Mar 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
26 changes: 23 additions & 3 deletions src/Console/Command/TaskCommand.php
Expand Up @@ -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;
Expand Down 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
73 changes: 73 additions & 0 deletions tests/AutocompleteTest.php
@@ -0,0 +1,73 @@
<?php

namespace Castor\Tests;

use Castor\Attribute\AsArgument;
use Castor\Attribute\AsTask;
use Castor\Console\Command\TaskCommand;
use Castor\EventDispatcher;
use Castor\ExpressionLanguage;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Tester\CommandCompletionTester;

class AutocompleteTest extends TaskTestCase
{
/** @dataProvider getData */
public function testCompletion(\Closure $function, array $expectedValues, string $input = '')
{
$reflectionFunction = new \ReflectionFunction($function);

$command = new TaskCommand(new AsTask('task'), $reflectionFunction, $this->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()));
}
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)"
}