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

Define opts/args on __invoke() params with Argument|Option attribute #27

Merged
merged 1 commit into from
Jul 12, 2022
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 104 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@
[![CI Status](https://github.com/zenstruck/console-extra/workflows/CI/badge.svg)](https://github.com/zenstruck/console-extra/actions?query=workflow%3ACI)
[![codecov](https://codecov.io/gh/zenstruck/console-extra/branch/1.x/graph/badge.svg?token=OEFPA53TDM)](https://codecov.io/gh/zenstruck/console-extra)

A modular set of features to reduce configuration boilerplate for your commands:
A modular set of features to reduce configuration boilerplate for your Symfony commands:

```php
/**
* Creates a user in the database.
*
* @command create:user email password --r|role[]
*/
#[AsCommand('create:user', 'Creates a user in the database.')]
final class CreateUserCommand extends InvokableServiceCommand
{
use ConfigureWithDocblocks, RunsCommands, RunsProcesses;
use ConfigureWithAttributes, RunsCommands, RunsProcesses;

public function __invoke(IO $io, UserRepository $repo, string $email, string $password, array $roles): void
{
public function __invoke(
IO $io,

UserRepository $repo,

#[Argument]
string $email,

#[Argument]
string $password,

#[Option(name: 'role', shortcut: 'r')]
array $roles,
): void {
$repo->createUser($email, $password, $roles);

$this->runCommand('another:command');
Expand Down Expand Up @@ -45,8 +53,9 @@ composer require zenstruck/console-extra

This library is a set of modular features that can be used separately or in combination.

**TIP**: To reduce command boilerplate even further, it is recommended to create an abstract base command for your
app that enables all the features you desire. Then have all your app's commands extend this.
> **Note**
> To reduce command boilerplate even further, it is recommended to create an abstract base command for your
> app that enables all the features you desire. Then have all your app's commands extend this.

### `IO`

Expand Down Expand Up @@ -78,8 +87,8 @@ into your command's `__invoke()` method. The following are parameters that can b
- `Symfony\Component\Console\Style\StyleInterface`
- `Symfony\Component\Console\Input\InputInterface`
- `Symfony\Component\Console\Input\OutputInterface`
- *arguments* (parameter name much match argument name)
- *options* (parameter name much match option name)
- *arguments* (parameter name must match argument name or use the `Zenstruck\Console\Attribute\Argument` attribute)
- *options* (parameter name must match option name or use the `Zenstruck\Console\Attribute\Option` attribute)

```php
use Symfony\Component\Console\Command\Command;
Expand Down Expand Up @@ -153,6 +162,76 @@ class CreateUserCommand extends InvokableServiceCommand
}
```

### `ConfigureWithAttributes`

Use this trait to use the `Argument` and `Option` attributes to configure your command's
arguments and options (_PHP 8+ required_):

```php
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Zenstruck\Console\Attribute\Argument;
use Zenstruck\Console\Attribute\Option;
use Zenstruck\Console\ConfigureWithAttributes;

#[Argument('arg1', description: 'Argument 1 description', mode: InputArgument::REQUIRED)]
#[Argument('arg2', description: 'Argument 1 description')]
#[Option('option1', description: 'Option 1 description')]
class MyCommand extends Command
{
use ConfigureWithAttributes;
}
```

> **Note**
> This trait is incompatible with [`ConfigureWithDocblocks`](#configurewithdocblocks).

#### Invokable Attributes

If using `ConfigureWithAttributes` and [`Invokable`](#invokable) together, you can add the
`Option`/`Argument` attributes to your `__invoke()` parameters to define and inject arguments/options:

```php
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Zenstruck\Console\Attribute\Argument;
use Zenstruck\Console\Attribute\Option;
use Zenstruck\Console\ConfigureWithAttributes;
use Zenstruck\Console\Invokable;

#[AsCommand('my:command')]
class MyCommand extends Command
{
use ConfigureWithAttributes, Invokable;

public function __invoke(
#[Argument]
string $username, // defined as a required argument (username)

#[Argument]
string $password = 'p4ssw0rd', // defined as an optional argument (password) with a default (p4ssw0rd)

#[Option(name: 'role', shortcut: 'r')]
array $roles = [], // defined as an array option that requires values (--r|role[])

#[Option(name: 'super-admin')]
bool $superAdmin = false, // defined as a "value-less" option (--super-admin)

#[Option]
?bool $force = null, // defined as a "negatable" option (--force/--no-force)

#[Option]
?string $name = null, // defined as an option that requires a value (--name=)
): void {
// ...
}
}
```

> **Note**
> Option/Argument _modes_ and _defaults_ are detected from the parameter's type-hint/default value
> and cannot be defined on the attribute.

### `AutoName`

Use this trait to have your command's name auto-generated from the class name:
Expand Down Expand Up @@ -186,29 +265,6 @@ class CreateUserCommand extends Command
}
```

### `ConfigureWithAttributes`

Use this trait to use the `Argument` and `Option` attributes to configure your command's
arguments and options (_PHP 8+ required_):

```php
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Zenstruck\Console\Attribute\Argument;
use Zenstruck\Console\Attribute\Option;
use Zenstruck\Console\ConfigureWithAttributes;

#[Argument('arg1', description: 'Argument 1 description', mode: InputArgument::REQUIRED)]
#[Argument('arg2', description: 'Argument 1 description')]
#[Option('option1', description: 'Option 1 description')]
class MyCommand extends Command
{
use ConfigureWithAttributes;
}
```

**NOTE:** This trait is incompatible with [`ConfigureWithDocblocks`](#configurewithdocblocks).

### `ConfigureWithDocblocks`

Use this trait to allow your command to be configured by your command class' docblock.
Expand Down Expand Up @@ -253,12 +309,12 @@ class MyCommand extends Command
}
```

**NOTES**:
1. If the `@command` tag is absent, [AutoName](#autoname) is used.
2. All the configuration can be disabled by using the traditional methods of configuring your command.
3. Command's are still [lazy](https://symfony.com/blog/new-in-symfony-3-4-lazy-commands) using this method of
configuration but there is overhead in parsing the docblocks so be aware of this.
4. This trait is incompatible with [`ConfigureWithAttributes`](#configurewithattributes).
> **Note**
> 1. If the `@command` tag is absent, [AutoName](#autoname) is used.
> 2. All the configuration can be disabled by using the traditional methods of configuring your command.
> 3. Command's are still [lazy](https://symfony.com/blog/new-in-symfony-3-4-lazy-commands) using this method of
> configuration but there is overhead in parsing the docblocks so be aware of this.
> 4. This trait is incompatible with [`ConfigureWithAttributes`](#configurewithattributes).

#### `@command` Tag

Expand All @@ -274,11 +330,10 @@ class MyCommand extends Command
}
```

**NOTES**:
1. The `|` prefix makes the command hidden.
2. Argument/Option descriptions are not allowed.

**TIP**: It is recommended to only do this for very simple commands as it isn't as explicit as splitting the tags out.
> **Note**
> 1. The `|` prefix makes the command hidden.
> 2. Argument/Option descriptions are not allowed.
> 3. It is recommended to only do this for very simple commands as it isn't as explicit as splitting the tags out.

### `CommandRunner`

Expand Down Expand Up @@ -420,4 +475,5 @@ services:
autoconfigure: true
```

**NOTE**: This will display a summary after every registered command runs.
> **Note**
> This will display a summary after every registered command runs.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zenstruck/console-extra",
"description": "Supercharge your Symfony console commands!",
"description": "A modular set of features to reduce configuration boilerplate for your Symfony commands.",
"homepage": "https://github.com/zenstruck/console-extra",
"type": "library",
"license": "MIT",
Expand Down
48 changes: 46 additions & 2 deletions src/Attribute/Argument.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,71 @@
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)]
final class Argument
{
/**
* @see InputArgument::__construct()
*/
public function __construct(
private string $name,
public ?string $name = null,
private ?int $mode = null,
private string $description = '',
private string|bool|int|float|array|null $default = null,
) {
}

/**
* @internal
*
* @return mixed[]|null
*/
public static function parseParameter(\ReflectionParameter $parameter): ?array
{
if (!$attributes = $parameter->getAttributes(self::class)) {
return null;
}

if (\count($attributes) > 1) {
throw new \LogicException(\sprintf('%s cannot be repeated when used as a parameter attribute.', self::class));
}

/** @var self $value */
$value = $attributes[0]->newInstance();
$value->name = $value->name ?? $parameter->name;

if ($value->mode) {
throw new \LogicException(\sprintf('Cannot use $mode when using %s as a parameter attribute, this is inferred from the parameter\'s type.', self::class));
}

if ($value->default) {
throw new \LogicException(\sprintf('Cannot use $default when using %s as a parameter attribute, this is inferred from the parameter\'s default value.', self::class));
}

$value->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED;

if ($parameter->getType() instanceof \ReflectionNamedType && 'array' === $parameter->getType()->getName()) {
$value->mode |= InputArgument::IS_ARRAY;
}

if ($parameter->isDefaultValueAvailable()) {
$value->default = $parameter->getDefaultValue();
}

return $value->values();
}

/**
* @internal
*
* @return mixed[]
*/
public function values(): array
{
if (!$this->name) {
throw new \LogicException(\sprintf('A $name is required when using %s as a command class attribute.', self::class));
}

return [$this->name, $this->mode, $this->description, $this->default];
}
}
50 changes: 48 additions & 2 deletions src/Attribute/Option.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,74 @@
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)]
final class Option
{
/**
* @see InputOption::__construct()
*/
public function __construct(
private string $name,
public ?string $name = null,
private string|array|null $shortcut = null,
private ?int $mode = null,
private string $description = '',
private string|bool|int|float|array|null $default = null,
) {
}

/**
* @internal
*
* @return mixed[]|null
*/
public static function parseParameter(\ReflectionParameter $parameter): ?array
{
if (!$attributes = $parameter->getAttributes(self::class)) {
return null;
}

if (\count($attributes) > 1) {
throw new \LogicException(\sprintf('%s cannot be repeated when used as a parameter attribute.', self::class));
}

/** @var self $value */
$value = $attributes[0]->newInstance();
$value->name = $value->name ?? $parameter->name;

if ($value->mode) {
throw new \LogicException(\sprintf('Cannot use $mode when using %s as a parameter attribute, this is inferred from the parameter\'s type.', self::class));
}

if ($value->default) {
throw new \LogicException(\sprintf('Cannot use $default when using %s as a parameter attribute, this is inferred from the parameter\'s default value.', self::class));
}

$name = $parameter->getType() instanceof \ReflectionNamedType ? $parameter->getType()->getName() : null;

$value->mode = match ($name) {
'array' => InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'bool' => \defined(InputOption::class.'::VALUE_NEGATABLE') && $parameter->allowsNull() ? InputOption::VALUE_NEGATABLE : InputOption::VALUE_NONE,
default => InputOption::VALUE_REQUIRED,
};

if ($value->mode ^ InputOption::VALUE_NONE && $parameter->isDefaultValueAvailable()) {
$value->default = $parameter->getDefaultValue();
}

return $value->values();
}

/**
* @internal
*
* @return mixed[]
*/
public function values(): array
{
if (!$this->name) {
throw new \LogicException(\sprintf('A $name is required when using %s as a command class attribute.', self::class));
}

return [$this->name, $this->shortcut, $this->mode, $this->description, $this->default];
}
}
Loading