diff --git a/src/Illuminate/Console/Attributes/Argument.php b/src/Illuminate/Console/Attributes/Argument.php new file mode 100644 index 000000000000..e690d7e1ed21 --- /dev/null +++ b/src/Illuminate/Console/Attributes/Argument.php @@ -0,0 +1,25 @@ +description; + } + + public function getAlias(): ?string + { + return $this->as; + } +} diff --git a/src/Illuminate/Console/Attributes/ArtisanCommand.php b/src/Illuminate/Console/Attributes/ArtisanCommand.php new file mode 100644 index 000000000000..158e27b3f5ee --- /dev/null +++ b/src/Illuminate/Console/Attributes/ArtisanCommand.php @@ -0,0 +1,16 @@ +description; + } + + public function getAlias(): ?string + { + return $this->as; + } + + public function getShortcut(): ?string + { + return $this->shortcut; + } + + public function isNegatable(): bool + { + return $this->negatable; + } +} diff --git a/src/Illuminate/Console/Command.php b/src/Illuminate/Console/Command.php index 6d9ae8c89381..f13567253c7d 100755 --- a/src/Illuminate/Console/Command.php +++ b/src/Illuminate/Console/Command.php @@ -2,6 +2,7 @@ namespace Illuminate\Console; +use Illuminate\Console\Reflections\CommandReflection; use Illuminate\Support\Traits\Macroable; use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\Input\InputInterface; @@ -12,6 +13,7 @@ class Command extends SymfonyCommand use Concerns\CallsCommands, Concerns\HasParameters, Concerns\InteractsWithIO, + Concerns\HasAttributeSyntax, Macroable; /** @@ -56,6 +58,13 @@ class Command extends SymfonyCommand */ protected $hidden = false; + /** + * The Reflection of the Command. + * + * @var CommandReflection + */ + protected CommandReflection $reflection; + /** * Create a new console command instance. * @@ -63,27 +72,36 @@ class Command extends SymfonyCommand */ public function __construct() { - // We will go ahead and set the name, description, and parameters on console - // commands just to make things a little easier on the developer. This is - // so they don't have to all be manually specified in the constructors. + $this->reflection = new CommandReflection($this); + $this->intiCommandData(); + $this->configureCommand(); + } + + /** + * Configure the console command using a fluent or attribute definition. + * + * We will go ahead and set the name, description, and parameters on console + * commands just to make things a little easier on the developer. This is + * so they don't have to all be manually specified in the constructors. + * + * @return void + */ + protected function configureCommand(): void + { if (isset($this->signature)) { $this->configureUsingFluentDefinition(); - } else { - parent::__construct($this->name); - } - - // Once we have constructed the command, we'll set the description and other - // related properties of the command. If a signature wasn't used to build - // the command we'll set the arguments and the options on this command. - $this->setDescription((string) $this->description); - $this->setHelp((string) $this->help); + return; + } - $this->setHidden($this->isHidden()); + if ($this->reflection->usesInputAttributes()) { + $this->configureUsingAttributeDefinition(); - if (! isset($this->signature)) { - $this->specifyParameters(); + return; } + + parent::__construct($this->name); + $this->specifyParameters(); } /** @@ -131,6 +149,9 @@ public function run(InputInterface $input, OutputInterface $output): int */ protected function execute(InputInterface $input, OutputInterface $output) { + $this->hydrateArguments(); + $this->hydrateOptions(); + $method = method_exists($this, 'handle') ? 'handle' : '__invoke'; return (int) $this->laravel->call([$this, $method]); @@ -161,6 +182,30 @@ protected function resolveCommand($command) return $command; } + /** + * Once we have constructed the command, we'll set the description and other + * related properties of the command ether by the command attribute or by command properties + * them self. + * + * @return void + */ + protected function intiCommandData(): void + { + if ($this->reflection->usesCommandAttribute()) { + $this->initCommandDataFromAttribute(); + + return; + } + + if (! isset($this->signature)) { + parent::__construct($this->name); + } + + $this->setDescription((string) $this->description); + $this->setHelp((string) $this->help); + $this->setHidden($this->isHidden()); + } + /** * {@inheritdoc} * diff --git a/src/Illuminate/Console/Concerns/HasAttributeSyntax.php b/src/Illuminate/Console/Concerns/HasAttributeSyntax.php new file mode 100644 index 000000000000..6cf380e04c9d --- /dev/null +++ b/src/Illuminate/Console/Concerns/HasAttributeSyntax.php @@ -0,0 +1,151 @@ +configureArgumentsUsingAttributeDefinition(); + $this->configureOptionsUsingAttributeDefinition(); + } + + protected function initCommandDataFromAttribute(): void + { + parent::__construct($this->name = $this->reflection->getName()); + $this->setDescription($this->reflection->getDescription()); + $this->setHelp($this->reflection->getHelp()); + $this->setHidden($this->reflection->isHidden()); + $this->setAliases($this->reflection->getAliases()); + } + + protected function configureArgumentsUsingAttributeDefinition(): void + { + $this->reflection + ->getArguments() + ->each(function (ArgumentReflection $argumentReflection) { + $this->getDefinition() + ->addArgument( + $this->propertyToArgument($argumentReflection) + ); + }); + } + + protected function configureOptionsUsingAttributeDefinition(): void + { + $this->reflection + ->getOptions() + ->each(function (OptionReflection $optionReflection) { + $this->getDefinition() + ->addOption($this->propertyToOption($optionReflection)); + }); + } + + protected function hydrateArguments(): void + { + $this->reflection + ->getArguments() + ->each(function (ArgumentReflection $argumentReflection) { + $this->{$argumentReflection->getName()} = $argumentReflection->castTo($this->argument($argumentReflection->getAlias() ?? $argumentReflection->getName())); + }); + } + + protected function hydrateOptions(): void + { + $this->reflection + ->getOptions() + ->each(function (OptionReflection $optionReflection) { + $consoleName = $optionReflection->getAlias() ?? $optionReflection->getName(); + + if (! $optionReflection->hasRequiredValue()) { + $this->{$optionReflection->getName()} = $optionReflection->castTo($this->option($consoleName)); + + return; + } + + if ($this->option($consoleName) === null) { + return; + } + + $this->{$optionReflection->getName()} = $optionReflection->castTo($this->option($consoleName)); + }); + } + + protected function propertyToArgument(ArgumentReflection $argument): InputArgument + { + return match (true) { + $argument->isArray() && ! $argument->isOptional() => $this->makeInputArgument($argument, + InputArgument::IS_ARRAY | InputArgument::REQUIRED), + + $argument->isArray() => $this->makeInputArgument($argument, InputArgument::IS_ARRAY, + $argument->getDefaultValue()), + + $argument->isOptional() || $argument->getDefaultValue() => $this->makeInputArgument($argument, + InputArgument::OPTIONAL, $argument->getDefaultValue()), + + default => $this->makeInputArgument($argument, InputArgument::REQUIRED), + }; + } + + protected function propertyToOption(OptionReflection $option): InputOption + { + return match (true) { + $option->hasValue() && $option->isArray() => $this->makeInputOption( + $option, + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + $option->getDefaultValue() + ), + + $option->hasValue() && ! $option->isOptional() => $this->makeInputOption($option, + InputOption::VALUE_REQUIRED), + + $option->hasValue() => $this->makeInputOption($option, InputOption::VALUE_OPTIONAL, + $option->getDefaultValue()), + + $option->isNegatable() => $this->makeInputOption( + $option, + InputOption::VALUE_NEGATABLE, + $option->getDefaultValue() !== null ? $option->getDefaultValue() : false + ), + + default => $this->makeInputOption($option, InputOption::VALUE_NONE), + }; + } + + protected function makeInputArgument( + ArgumentReflection $argument, + int $mode, + string|bool|int|float|array|null $default = null + ): InputArgument { + return new InputArgument( + $argument->getAlias() ?? $argument->getName(), + $mode, + $argument->getDescription(), + $default + ); + } + + protected function makeInputOption( + OptionReflection $option, + int $mode, + string|bool|int|float|array|null $default = null + ): InputOption { + return new InputOption( + $option->getAlias() ?? $option->getName(), + $option->getShortcut(), + $mode, + $option->getDescription(), + $default + ); + } +} diff --git a/src/Illuminate/Console/Reflections/ArgumentReflection.php b/src/Illuminate/Console/Reflections/ArgumentReflection.php new file mode 100644 index 000000000000..2f9792f4c7ba --- /dev/null +++ b/src/Illuminate/Console/Reflections/ArgumentReflection.php @@ -0,0 +1,13 @@ +getAttributes(Argument::class)); + } +} diff --git a/src/Illuminate/Console/Reflections/CommandReflection.php b/src/Illuminate/Console/Reflections/CommandReflection.php new file mode 100644 index 000000000000..af0f3bc8a91a --- /dev/null +++ b/src/Illuminate/Console/Reflections/CommandReflection.php @@ -0,0 +1,115 @@ +reflection = new \ReflectionClass($this->command); + $this->attribute = $this->initCommandAttribute(); + } + + public function usesAttributeSyntax(): bool + { + return $this->usesCommandAttribute() || $this->usesInputAttributes(); + } + + public function usesCommandAttribute(): bool + { + return $this->attribute !== null; + } + + public function usesInputAttributes(): bool + { + return $this->getArguments()->isNotEmpty() || $this->getOptions()->isNotEmpty(); + } + + public function getArguments(): Collection + { + return collect($this->reflection->getProperties()) + ->filter(fn (\ReflectionProperty $property) => ArgumentReflection::isArgument($property)) + ->map(fn (\ReflectionProperty $property) => new ArgumentReflection( + $property, + $property->getAttributes(Argument::class)[0]->newInstance() + ) + ); + } + + public function getOptions(): Collection + { + return collect($this->reflection->getProperties()) + ->filter(fn (\ReflectionProperty $property) => OptionReflection::isOption($property)) + ->map(fn (\ReflectionProperty $property) => new OptionReflection( + $property, + $property->getAttributes(Option::class)[0]->newInstance() + ) + ); + } + + public function getName(): ?string + { + if (! $this->usesCommandAttribute()) { + return $this->command->getName(); + } + + return $this->attribute->name; + } + + public function getDescription(): string + { + if (! $this->usesCommandAttribute()) { + return $this->command->getDescription(); + } + + return $this->attribute->description; + } + + public function getHelp(): string + { + if (! $this->usesCommandAttribute()) { + return $this->command->getHelp(); + } + + return $this->attribute->help; + } + + public function isHidden(): bool + { + if (! $this->usesCommandAttribute()) { + return $this->command->isHidden(); + } + + return $this->attribute->hidden; + } + + public function getAliases(): array + { + if (! $this->usesCommandAttribute()) { + return []; + } + + return $this->attribute->aliases; + } + + protected function initCommandAttribute(): ?ArtisanCommand + { + $attributes = $this->reflection->getAttributes(ArtisanCommand::class); + + if (empty($attributes)) { + return null; + } + + return $attributes[0]->newInstance(); + } +} diff --git a/src/Illuminate/Console/Reflections/InputReflection.php b/src/Illuminate/Console/Reflections/InputReflection.php new file mode 100644 index 000000000000..b6954461b761 --- /dev/null +++ b/src/Illuminate/Console/Reflections/InputReflection.php @@ -0,0 +1,91 @@ +property->getName(); + } + + public function getAlias(): ?string + { + return $this->consoleInput->getAlias(); + } + + public function getDescription(): string + { + return $this->consoleInput->getDescription(); + } + + public function getDefaultValue(): string|bool|int|float|array|null + { + return $this->property->hasDefaultValue() + ? $this->castFrom($this->property->getDefaultValue()) + : null; + } + + public function isOptional(): bool + { + return $this->property->hasDefaultValue() || $this->property->getType()?->allowsNull(); + } + + public function isArray(): bool + { + if (($type = $this->property->getType()) instanceof \ReflectionNamedType) { + return $type->getName() === 'array'; + } + + return false; + } + + public function castFrom(mixed $value): int|float|array|string|bool|null + { + return match (gettype($value)) { + 'integer', 'NULL', 'boolean', 'double', 'string', 'array' => $value, + 'object' => function_exists('enum_exists') && enum_exists($value::class) ? $this->castEnum($value) : $value, + default => $value, + }; + } + + public function castEnum(object $value): int|float|array|string|bool|null + { + return (new \ReflectionEnum($value))->isBacked() + ? $value->value + : $value->name; + } + + public function castTo(int|array|float|string|bool|null $value): mixed + { + if (! ($type = $this->property->getType())) { + return $value; + } + + if (! $type instanceof \ReflectionNamedType) { + return $value; + } + + if (! function_exists('enum_exists')) { + return $value; + } + + if (! enum_exists($type->getName())) { + return $value; + } + + $enum = new \ReflectionEnum($type->getName()); + + return $enum->isBacked() + ? ($type->getName())::from((string) $value) + : $enum->getCase((string) $value)->getValue(); + } +} diff --git a/src/Illuminate/Console/Reflections/OptionReflection.php b/src/Illuminate/Console/Reflections/OptionReflection.php new file mode 100644 index 000000000000..8c81b8e5f960 --- /dev/null +++ b/src/Illuminate/Console/Reflections/OptionReflection.php @@ -0,0 +1,42 @@ +getAttributes(Option::class)); + } + + public function isNegatable(): bool + { + return $this->consoleInput->isNegatable(); + } + + public function hasRequiredValue(): bool + { + return $this->hasValue() && ! $this->isOptional(); + } + + public function getShortcut(): ?string + { + return $this->consoleInput->getShortcut(); + } + + public function hasValue(): bool + { + if (($type = $this->property->getType()) instanceof \ReflectionNamedType) { + return $type->getName() !== 'bool'; + } + + return false; + } +} diff --git a/src/Illuminate/Contracts/Console/ConsoleInput.php b/src/Illuminate/Contracts/Console/ConsoleInput.php new file mode 100644 index 000000000000..cdaa1cf0a5f6 --- /dev/null +++ b/src/Illuminate/Contracts/Console/ConsoleInput.php @@ -0,0 +1,10 @@ += 80100) { + include 'Enums.php'; +} + +class CommandAttributesTest extends TestCase +{ + public function testAttributeWillBeUsed() + { + $command = new AttributeCommand(); + $command = $this->callCommand($command); + + $this->assertSame('test:basic', $command->getName()); + $this->assertSame('Basic Command description!', $command->getDescription()); + $this->assertSame('Some Help.', $command->getHelp()); + $this->assertTrue($command->isHidden()); + $this->assertSame(['alias:basic'], $command->getAliases()); + } + + public function testArgumentsWillBeRegisteredWithAttributeSyntax() + { + $command = new class extends Command + { + protected $name = 'test'; + + #[Argument] + public string $requiredArgument; + + #[Argument] + public ?string $optionalArgument; + + #[Argument] + public string $defaultArgument = 'default_value'; + + public function handle() + { + } + }; + + $definition = $command->getDefinition(); + + $command = $this->callCommand($command, [ + 'requiredArgument' => 'Argument_Required', + 'optionalArgument' => 'Argument_Optional', + 'defaultArgument' => 'Argument_Default', + ]); + + $this->assertTrue($definition->getArgument('requiredArgument')->isRequired()); + $this->assertSame('Argument_Required', $command->requiredArgument); + + $this->assertFalse($definition->getArgument('optionalArgument')->isRequired()); + $this->assertSame('Argument_Optional', $command->optionalArgument); + + $this->assertSame('default_value', $definition->getArgument('defaultArgument')->getDefault()); + $this->assertSame('Argument_Default', $command->defaultArgument); + } + + public function testArrayArgumentsWillBeRegisteredWithAttributeSyntax() + { + $commandRequired = new class extends Command + { + protected $name = 'test'; + + #[Argument] + public array $arrayArgument; + + public function handle() + { + } + }; + + $commandOptional = new class extends Command + { + protected $name = 'test'; + + #[Argument] + public ?array $optionalArrayArgument; + + public function handle() + { + } + }; + + $commandDefault = new class extends Command + { + protected $name = 'test'; + + #[Argument] + public array $defaultArrayArgument = ['Value A', 'Value B']; + + public function handle() + { + } + }; + + $commandRequired = $this->callCommand($commandRequired, [ + 'arrayArgument' => ['Array_Required'], + ]); + + $definition = $commandRequired->getDefinition(); + + $this->assertTrue($definition->getArgument('arrayArgument')->isArray()); + $this->assertTrue($definition->getArgument('arrayArgument')->isRequired()); + $this->assertSame(['Array_Required'], $commandRequired->arrayArgument); + + $commandOptional = $this->callCommand($commandOptional, [ + 'optionalArrayArgument' => ['Array_Optional'], + ]); + + $definition = $commandOptional->getDefinition(); + + $this->assertTrue($definition->getArgument('optionalArrayArgument')->isArray()); + $this->assertFalse($definition->getArgument('optionalArrayArgument')->isRequired()); + $this->assertSame(['Array_Optional'], $commandOptional->optionalArrayArgument); + + $commandDefault = $this->callCommand($commandDefault, [ + 'defaultArrayArgument' => ['Array_Default'], + ]); + + $definition = $commandDefault->getDefinition(); + + $this->assertTrue($definition->getArgument('defaultArrayArgument')->isArray()); + $this->assertFalse($definition->getArgument('defaultArrayArgument')->isRequired()); + $this->assertSame(['Value A', 'Value B'], $definition->getArgument('defaultArrayArgument')->getDefault()); + $this->assertSame(['Array_Default'], $commandDefault->defaultArrayArgument); + + $commandDefault = $this->callCommand($commandDefault, []); + $this->assertSame(['Value A', 'Value B'], $commandDefault->defaultArrayArgument); + } + + public function testOptionsWillBeRegisteredWithAttributeSyntax() + { + $command = new class extends Command + { + protected $name = 'test'; + + #[Option] + public bool $option; + + #[Option] + public string $optionWithValue; + + #[Option] + public ?string $optionWithNullableValue; + + #[Option] + public string $optionWithDefaultValue = 'default'; + + public function handle() + { + } + }; + + $definition = $command->getDefinition(); + + $command = $this->callCommand($command, [ + '--option' => true, + '--optionWithValue' => 'Value A', + ]); + + $this->assertFalse($definition->getOption('option')->isValueOptional()); + $this->assertTrue($command->option); + + $this->assertTrue($definition->getOption('optionWithValue')->isValueRequired()); + $this->assertSame('Value A', $command->optionWithValue); + + $this->assertTrue($definition->getOption('optionWithNullableValue')->isValueOptional()); + $this->assertNull($command->optionWithNullableValue); + + $command = $this->callCommand($command, [ + '--optionWithNullableValue' => 'Value B', + ]); + $this->assertSame('Value B', $command->optionWithNullableValue); + + $this->assertSame('default', $definition->getOption('optionWithDefaultValue')->getDefault()); + + $command = $this->callCommand($command, [ + '--optionWithDefaultValue' => 'Value C', + ]); + $this->assertSame('Value C', $command->optionWithDefaultValue); + } + + public function testArrayOptionsWillBeRegisteredWithAttributeSyntax() + { + $command = new class extends Command + { + protected $name = 'test'; + + #[Option] + public array $optionArray; + + #[Option] + public array $optionDefaultArray = ['default1', 'default2']; + + public function handle() + { + } + }; + + $definition = $command->getDefinition(); + + $command = $this->callCommand($command, []); + $this->assertSame([], $command->optionArray); + + $command = $this->callCommand($command, [ + '--optionArray' => ['Value A', 'Value B'], + ]); + + $this->assertTrue($definition->getOption('optionArray')->isArray()); + $this->assertSame(['Value A', 'Value B'], $command->optionArray); + + $this->assertTrue($definition->getOption('optionArray')->isArray()); + $this->assertTrue($definition->getOption('optionArray')->isValueOptional()); + $this->assertSame(['Value A', 'Value B'], $command->optionArray); + + $command = $this->callCommand($command, [ + '--optionDefaultArray' => ['Value C', 'Value D'], + ]); + + $this->assertSame(['Value C', 'Value D'], $command->optionDefaultArray); + } + + public function testInputMetaDataWillBeRegisteredWithAttributeSyntax() + { + $command = new class extends Command + { + protected $name = 'test'; + + #[Argument( + as: 'argumentAlias', + description: 'Argument Description', + )] + public string $argument = ''; + + #[Option( + as: 'optionAlias', + description: 'Option Description' + )] + public bool $option; + + public function handle() + { + } + }; + + $definition = $command->getDefinition(); + + $this->assertSame('Argument Description', $definition->getArgument('argumentAlias')->getDescription()); + $this->assertSame('Option Description', $definition->getOption('optionAlias')->getDescription()); + + $command = $this->callCommand($command, [ + 'argumentAlias' => 'Value', + '--optionAlias' => true, + ]); + + $this->assertSame('Value', $command->argument); + $this->assertSame(true, $command->option); + } + + public function testOptionShortcutWillBeRegisteredWithAttributeSyntax() + { + $command = new class extends Command + { + protected $name = 'test'; + + #[Option( + shortcut: 'O' + )] + public string $option; + + public function handle() + { + } + }; + + $definition = $command->getDefinition(); + + $this->assertSame('O', $definition->getOption('option')->getShortcut()); + + $command = $this->callCommand($command, [ + '-O' => 'short', + ]); + + $this->assertSame('short', $command->option); + } + + public function testOptionNegatableWillBeRegisteredWithAttributeSyntax() + { + $command = new class extends Command + { + protected $name = 'test'; + + #[Option( + negatable: true + )] + public bool $option; + + public function handle() + { + } + }; + + $definition = $command->getDefinition(); + + $this->assertTrue($definition->getOption('option')->isNegatable()); + + $command = $this->callCommand($command, [ + '--option' => true, + ]); + + $this->assertTrue($command->option); + + $command = $this->callCommand($command, [ + '--no-option' => true, + ]); + + $this->assertFalse($command->option); + } + + /** + * @requires PHP >= 8.1 + */ + public function testArgumentEnumsWillBeCasted() + { + if (PHP_VERSION_ID <= 80100) { + $this->markTestSkipped('Enum Casting test skipped caused by PHP version.'); + + return; + } + + $command = new class extends Command + { + protected $name = 'test'; + + #[Argument] + public Enum $enumArgument; + + #[Argument] + public StringEnum $enumStringArgument; + + #[Argument] + public IntEnum $enumIntArgument; + + #[Argument] + public StringEnum $enumDefaultArgument = StringEnum::B; + + public function handle() + { + } + }; + + $command = $this->callCommand($command, [ + 'enumArgument' => 'B', + 'enumStringArgument' => 'String B', + 'enumIntArgument' => 2, + ]); + + $this->assertSame(Enum::B, $command->enumArgument); + + $this->assertSame(StringEnum::B, $command->enumStringArgument); + + $this->assertSame(IntEnum::B, $command->enumIntArgument); + + $this->assertSame(StringEnum::B, $command->enumDefaultArgument); + } + + /** + * @requires PHP >= 8.1 + */ + public function testOptionEnumsWillBeCasted() + { + if (PHP_VERSION_ID <= 80100) { + $this->markTestSkipped('Enum Casting test skipped caused by PHP version.'); + + return; + } + + $command = new class extends Command + { + protected $name = 'test'; + + #[Option] + public Enum $enumOption; + + #[Option] + public StringEnum $enumStringOption; + + #[Option] + public IntEnum $enumIntOption; + + #[Option] + public StringEnum $enumDefaultOption = StringEnum::B; + + public function handle() + { + } + }; + + $command = $this->callCommand($command, [ + '--enumOption' => 'B', + '--enumStringOption' => 'String B', + '--enumIntOption' => 2, + ]); + + $this->assertSame(Enum::B, $command->enumOption); + + $this->assertSame(StringEnum::B, $command->enumStringOption); + + $this->assertSame(IntEnum::B, $command->enumIntOption); + + $this->assertSame(StringEnum::B, $command->enumDefaultOption); + } + + protected function callCommand(Command $command, array $input = []): Command + { + $application = app(); + $command->setLaravel($application); + + $input = new ArrayInput($input); + $output = new NullOutput(); + + $command->run($input, $output); + + return $command; + } +} diff --git a/tests/Console/Enums.php b/tests/Console/Enums.php new file mode 100644 index 000000000000..ccce707f4ef0 --- /dev/null +++ b/tests/Console/Enums.php @@ -0,0 +1,24 @@ +