Skip to content

[Console] Invokable command adjustments #59493

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

Merged
merged 1 commit into from
Jan 20, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ public function load(array $configs, ContainerBuilder $container): void
$container->registerForAutoconfiguration(AssetCompilerInterface::class)
->addTag('asset_mapper.compiler');
$container->registerAttributeForAutoconfiguration(AsCommand::class, static function (ChildDefinition $definition, AsCommand $attribute, \ReflectionClass $reflector): void {
$definition->addTag('console.command', ['command' => $attribute->name, 'description' => $attribute->description ?? $reflector->getName()]);
$definition->addTag('console.command', ['command' => $attribute->name, 'description' => $attribute->description]);
});
$container->registerForAutoconfiguration(Command::class)
->addTag('console.command');
Expand Down
22 changes: 10 additions & 12 deletions src/Symfony/Component/Console/Attribute/Argument.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,23 @@ class Argument
{
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];

private string|bool|int|float|array|null $default = null;
private array|\Closure $suggestedValues;
private ?int $mode = null;

/**
* Represents a console command <argument> definition.
*
* If unset, the `name` and `default` values will be inferred from the parameter definition.
* If unset, the `name` value 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
* @param array<string|Suggestion>|callable(CompletionInput):list<string|Suggestion> $suggestedValues The values used for input completion
*/
public function __construct(
public string $name = '',
public string $description = '',
public string|bool|int|float|array|null $default = null,
public array|string $suggestedValues = [],
array|callable $suggestedValues = [],
) {
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__));
}
$this->suggestedValues = \is_callable($suggestedValues) ? $suggestedValues(...) : $suggestedValues;
}

/**
Expand Down Expand Up @@ -70,13 +68,13 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
$self->name = $name;
}

$self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
$self->default = $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null;

$self->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
if ('array' === $parameterTypeName) {
$self->mode |= InputArgument::IS_ARRAY;
}

$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]];
}
Expand All @@ -99,6 +97,6 @@ public function toInputArgument(): InputArgument
*/
public function resolveValue(InputInterface $input): mixed
{
return $input->hasArgument($this->name) ? $input->getArgument($this->name) : null;
return $input->getArgument($this->name);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed $input->hasArgument($this->name) as it's better to get the "argument does not exist" exception than a TypeError or just null (if nullable)

}
}
52 changes: 31 additions & 21 deletions src/Symfony/Component/Console/Attribute/Option.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,27 @@ class Option
{
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];

private string|bool|int|float|array|null $default = null;
private array|\Closure $suggestedValues;
private ?int $mode = null;
private string $typeName = '';
private bool $allowNull = false;

/**
* Represents a console command --option definition.
*
* If unset, the `name` and `default` values will be inferred from the parameter definition.
* If unset, the `name` value 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
* @param array|string|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts
* @param array<string|Suggestion>|callable(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 = [],
array|callable $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__));
}
$this->suggestedValues = \is_callable($suggestedValues) ? $suggestedValues(...) : $suggestedValues;
}

/**
Expand All @@ -69,25 +68,29 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
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 (!$parameter->isDefaultValueAvailable()) {
throw new LogicException(\sprintf('The option parameter "$%s" must declare a default value.', $name));
}

if (!$self->name) {
$self->name = $name;
}

$self->default = $parameter->getDefaultValue();
$self->allowNull = $parameter->allowsNull();

if ('bool' === $self->typeName) {
$self->mode = InputOption::VALUE_NONE | InputOption::VALUE_NEGATABLE;
$self->mode = InputOption::VALUE_NONE;
if (false !== $self->default) {
$self->mode |= InputOption::VALUE_NEGATABLE;
}
} else {
$self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
$self->mode = $self->allowNull ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
if ('array' === $self->typeName) {
$self->mode |= InputOption::VALUE_IS_ARRAY;
}
}

if (InputOption::VALUE_NONE === (InputOption::VALUE_NONE & $self->mode)) {
$self->default = null;
} 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]];
}
Expand All @@ -100,20 +103,27 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
*/
public function toInputOption(): InputOption
{
$default = InputOption::VALUE_NONE === (InputOption::VALUE_NONE & $this->mode) ? null : $this->default;
$suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues;

return new InputOption($this->name, $this->shortcut, $this->mode, $this->description, $this->default, $suggestedValues);
return new InputOption($this->name, $this->shortcut, $this->mode, $this->description, $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);
$value = $input->getOption($this->name);

if ('bool' !== $this->typeName) {
return $value;
}

if ($this->allowNull && null === $value) {
return null;
}

return $input->hasOption($this->name) ? $input->getOption($this->name) : null;
return $value ?? $this->default;
}
}
23 changes: 4 additions & 19 deletions src/Symfony/Component/Console/Command/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ public function __construct(?string $name = null)
$this->setDescription(static::getDefaultDescription() ?? '');
}

if (\is_callable($this)) {
$this->code = new InvokableCommand($this, $this(...));
}

$this->configure();
}

Expand Down Expand Up @@ -164,9 +168,6 @@ public function isEnabled(): bool
*/
protected function configure()
{
if (!$this->code && \is_callable($this)) {
$this->code = new InvokableCommand($this, $this(...));
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to the constructor to allow overriding the configure() method without requiring a call to the parent implementation

}

/**
Expand Down Expand Up @@ -312,22 +313,6 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti
*/
public function setCode(callable $code): static
{
if ($code instanceof \Closure) {
$r = new \ReflectionFunction($code);
if (null === $r->getClosureThis()) {
set_error_handler(static function () {});
try {
if ($c = \Closure::bind($code, $this)) {
$code = $c;
}
} finally {
restore_error_handler();
}
}
} else {
$code = $code(...);
}
Copy link
Member Author

@yceruto yceruto Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to InvokableCommand which supports handling static anonymous functions too


$this->code = new InvokableCommand($this, $code, triggerDeprecations: true);

return $this;
Expand Down
32 changes: 28 additions & 4 deletions src/Symfony/Component/Console/Command/InvokableCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,16 @@
*/
class InvokableCommand
{
private readonly \Closure $code;
private readonly \ReflectionFunction $reflection;

public function __construct(
private readonly Command $command,
private readonly \Closure $code,
callable $code,
private readonly bool $triggerDeprecations = false,
) {
$this->reflection = new \ReflectionFunction($code);
$this->code = $this->getClosure($code);
$this->reflection = new \ReflectionFunction($this->code);
}

/**
Expand All @@ -49,7 +51,7 @@

if (null !== $statusCode && !\is_int($statusCode)) {
if ($this->triggerDeprecations) {
trigger_deprecation('symfony/console', '7.3', \sprintf('Returning a non-integer value from the command "%s" is deprecated and will throw an exception in PHP 8.0.', $this->command->getName()));
trigger_deprecation('symfony/console', '7.3', \sprintf('Returning a non-integer value from the command "%s" is deprecated and will throw an exception in Symfony 8.0.', $this->command->getName()));

return 0;
}
Expand Down Expand Up @@ -77,6 +79,28 @@
}
}

private function getClosure(callable $code): \Closure
{
if (!$code instanceof \Closure) {
return $code(...);
}

if (null !== (new \ReflectionFunction($code))->getClosureThis()) {
return $code;
}

set_error_handler(static function () {});

Check failure on line 92 in src/Symfony/Component/Console/Command/InvokableCommand.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidArgument

src/Symfony/Component/Console/Command/InvokableCommand.php:92:27: InvalidArgument: Argument 1 of set_error_handler expects callable(int, string, string=, int=, array<array-key, mixed>=):bool|null, but pure-Closure():void provided (see https://psalm.dev/004)

Check failure on line 92 in src/Symfony/Component/Console/Command/InvokableCommand.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidArgument

src/Symfony/Component/Console/Command/InvokableCommand.php:92:27: InvalidArgument: Argument 1 of set_error_handler expects callable(int, string, string=, int=, array<array-key, mixed>=):bool|null, but pure-Closure():void provided (see https://psalm.dev/004)
try {
if ($c = \Closure::bind($code, $this->command)) {
$code = $c;
}
} finally {
restore_error_handler();
}

return $code;
}

private function getParameters(InputInterface $input, OutputInterface $output): array
{
$parameters = [];
Expand All @@ -97,7 +121,7 @@

if (!$type instanceof \ReflectionNamedType) {
if ($this->triggerDeprecations) {
trigger_deprecation('symfony/console', '7.3', \sprintf('Omitting the type declaration for the parameter "$%s" is deprecated and will throw an exception in PHP 8.0.', $parameter->getName()));
trigger_deprecation('symfony/console', '7.3', \sprintf('Omitting the type declaration for the parameter "$%s" is deprecated and will throw an exception in Symfony 8.0.', $parameter->getName()));

continue;
}
Expand Down
Loading
Loading