-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[Console] Add support for invokable commands and input attributes #59340
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Component\Console\Attribute; | ||
|
||
use Symfony\Component\Console\Completion\CompletionInput; | ||
use Symfony\Component\Console\Completion\Suggestion; | ||
use Symfony\Component\Console\Exception\LogicException; | ||
use Symfony\Component\Console\Input\InputArgument; | ||
use Symfony\Component\Console\Input\InputInterface; | ||
|
||
#[\Attribute(\Attribute::TARGET_PARAMETER)] | ||
class Argument | ||
{ | ||
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array']; | ||
|
||
private ?int $mode = null; | ||
|
||
/** | ||
* Represents a console command <argument> definition. | ||
* | ||
* If unset, the `name` and `default` values will be inferred from the parameter definition. | ||
* | ||
* @param string|bool|int|float|array|null $default The default value (for InputArgument::OPTIONAL mode only) | ||
* @param array|callable-string(CompletionInput):list<string|Suggestion> $suggestedValues The values used for input completion | ||
yceruto marked this conversation as resolved.
Show resolved
Hide resolved
|
||
*/ | ||
public function __construct( | ||
public string $name = '', | ||
public string $description = '', | ||
public string|bool|int|float|array|null $default = null, | ||
public array|string $suggestedValues = [], | ||
yceruto marked this conversation as resolved.
Show resolved
Hide resolved
|
||
) { | ||
if (\is_string($suggestedValues) && !\is_callable($suggestedValues)) { | ||
throw new \TypeError(\sprintf('Argument 4 passed to "%s()" must be either an array or a callable-string.', __METHOD__)); | ||
} | ||
} | ||
|
||
/** | ||
* @internal | ||
*/ | ||
public static function tryFrom(\ReflectionParameter $parameter): ?self | ||
{ | ||
/** @var self $self */ | ||
if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) { | ||
return null; | ||
} | ||
welcoMattic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
$type = $parameter->getType(); | ||
$name = $parameter->getName(); | ||
|
||
if (!$type instanceof \ReflectionNamedType) { | ||
throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $name)); | ||
} | ||
|
||
$parameterTypeName = $type->getName(); | ||
|
||
if (!\in_array($parameterTypeName, self::ALLOWED_TYPES, true)) { | ||
throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command argument. Only "%s" types are allowed.', $parameterTypeName, $name, implode('", "', self::ALLOWED_TYPES))); | ||
} | ||
|
||
if (!$self->name) { | ||
$self->name = $name; | ||
} | ||
|
||
$self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputArgument::OPTIONAL : InputArgument::REQUIRED; | ||
if ('array' === $parameterTypeName) { | ||
$self->mode |= InputArgument::IS_ARRAY; | ||
} | ||
yceruto marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
$self->default ??= $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; | ||
|
||
if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) { | ||
$self->suggestedValues = [$instance, $self->suggestedValues[1]]; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [note for reviewer] this falls back from the "static class method call" syntax to the "object method call" syntax due to the impossibility of passing a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For instance, this will allow us to configure |
||
|
||
return $self; | ||
} | ||
|
||
/** | ||
* @internal | ||
*/ | ||
public function toInputArgument(): InputArgument | ||
{ | ||
$suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues; | ||
|
||
return new InputArgument($this->name, $this->mode, $this->description, $this->default, $suggestedValues); | ||
yceruto marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
/** | ||
* @internal | ||
*/ | ||
public function resolveValue(InputInterface $input): mixed | ||
{ | ||
return $input->hasArgument($this->name) ? $input->getArgument($this->name) : null; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,119 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Component\Console\Attribute; | ||
|
||
use Symfony\Component\Console\Completion\CompletionInput; | ||
use Symfony\Component\Console\Completion\Suggestion; | ||
use Symfony\Component\Console\Exception\LogicException; | ||
use Symfony\Component\Console\Input\InputInterface; | ||
use Symfony\Component\Console\Input\InputOption; | ||
|
||
#[\Attribute(\Attribute::TARGET_PARAMETER)] | ||
class Option | ||
{ | ||
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array']; | ||
|
||
private ?int $mode = null; | ||
private string $typeName = ''; | ||
chalasr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
/** | ||
* Represents a console command --option definition. | ||
* | ||
* If unset, the `name` and `default` values will be inferred from the parameter definition. | ||
* | ||
* @param array|string|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts | ||
* @param scalar|array|null $default The default value (must be null for self::VALUE_NONE) | ||
* @param array|callable-string(CompletionInput):list<string|Suggestion> $suggestedValues The values used for input completion | ||
*/ | ||
public function __construct( | ||
public string $name = '', | ||
public array|string|null $shortcut = null, | ||
public string $description = '', | ||
public string|bool|int|float|array|null $default = null, | ||
public array|string $suggestedValues = [], | ||
) { | ||
if (\is_string($suggestedValues) && !\is_callable($suggestedValues)) { | ||
throw new \TypeError(\sprintf('Argument 5 passed to "%s()" must be either an array or a callable-string.', __METHOD__)); | ||
} | ||
} | ||
|
||
/** | ||
* @internal | ||
*/ | ||
public static function tryFrom(\ReflectionParameter $parameter): ?self | ||
{ | ||
/** @var self $self */ | ||
if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) { | ||
chalasr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return null; | ||
} | ||
|
||
$type = $parameter->getType(); | ||
$name = $parameter->getName(); | ||
|
||
if (!$type instanceof \ReflectionNamedType) { | ||
throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported for command options.', $name)); | ||
} | ||
|
||
$self->typeName = $type->getName(); | ||
|
||
if (!\in_array($self->typeName, self::ALLOWED_TYPES, true)) { | ||
throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, implode('", "', self::ALLOWED_TYPES))); | ||
} | ||
|
||
if (!$self->name) { | ||
$self->name = $name; | ||
chalasr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
if ('bool' === $self->typeName) { | ||
$self->mode = InputOption::VALUE_NONE | InputOption::VALUE_NEGATABLE; | ||
} else { | ||
$self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED; | ||
yceruto marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if ('array' === $self->typeName) { | ||
$self->mode |= InputOption::VALUE_IS_ARRAY; | ||
} | ||
} | ||
|
||
if (InputOption::VALUE_NONE === (InputOption::VALUE_NONE & $self->mode)) { | ||
$self->default = null; | ||
yceruto marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} else { | ||
$self->default ??= $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; | ||
} | ||
|
||
if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) { | ||
$self->suggestedValues = [$instance, $self->suggestedValues[1]]; | ||
} | ||
|
||
return $self; | ||
} | ||
|
||
/** | ||
* @internal | ||
*/ | ||
public function toInputOption(): InputOption | ||
{ | ||
$suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues; | ||
|
||
return new InputOption($this->name, $this->shortcut, $this->mode, $this->description, $this->default, $suggestedValues); | ||
} | ||
|
||
/** | ||
* @internal | ||
*/ | ||
public function resolveValue(InputInterface $input): mixed | ||
{ | ||
if ('bool' === $this->typeName) { | ||
return $input->hasOption($this->name) && null !== $input->getOption($this->name) ? $input->getOption($this->name) : ($this->default ?? false); | ||
} | ||
|
||
return $input->hasOption($this->name) ? $input->getOption($this->name) : null; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -49,7 +49,7 @@ | |
private string $description = ''; | ||
private ?InputDefinition $fullDefinition = null; | ||
private bool $ignoreValidationErrors = false; | ||
private ?\Closure $code = null; | ||
private ?InvokableCommand $code = null; | ||
private array $synopsis = []; | ||
private array $usages = []; | ||
private ?HelperSet $helperSet = null; | ||
|
@@ -164,6 +164,9 @@ | |
*/ | ||
protected function configure() | ||
{ | ||
if (!$this->code && \is_callable($this)) { | ||
$this->code = new InvokableCommand($this, $this(...)); | ||
Check failure on line 168 in src/Symfony/Component/Console/Command/Command.php
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The psalm errors seems incorrect. It can be added to the baseline.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This downside of this is we're now creating a self-referencing class. Might not be an issue in practice since we won't create several instances of the command object, but still worth to have in mind and prevent writing such code in the generic case. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Completely agree here! I'm wondering if there is an alternative solution for this case… |
||
} | ||
} | ||
|
||
/** | ||
|
@@ -274,12 +277,10 @@ | |
$input->validate(); | ||
|
||
if ($this->code) { | ||
$statusCode = ($this->code)($input, $output); | ||
} else { | ||
$statusCode = $this->execute($input, $output); | ||
return ($this->code)($input, $output); | ||
} | ||
|
||
return is_numeric($statusCode) ? (int) $statusCode : 0; | ||
return $this->execute($input, $output); | ||
} | ||
|
||
/** | ||
|
@@ -327,7 +328,7 @@ | |
$code = $code(...); | ||
} | ||
|
||
$this->code = $code; | ||
$this->code = new InvokableCommand($this, $code); | ||
|
||
return $this; | ||
} | ||
|
@@ -395,7 +396,13 @@ | |
*/ | ||
public function getNativeDefinition(): InputDefinition | ||
{ | ||
return $this->definition ?? throw new LogicException(\sprintf('Command class "%s" is not correctly initialized. You probably forgot to call the parent constructor.', static::class)); | ||
$definition = $this->definition ?? throw new LogicException(\sprintf('Command class "%s" is not correctly initialized. You probably forgot to call the parent constructor.', static::class)); | ||
|
||
if ($this->code && !$definition->getArguments() && !$definition->getOptions()) { | ||
$this->code->configure($definition); | ||
} | ||
|
||
return $definition; | ||
} | ||
|
||
/** | ||
|
Uh oh!
There was an error while loading. Please reload this page.