Skip to content

Commit

Permalink
[feature] define opts/args on __invoke() params with Argument|Option
Browse files Browse the repository at this point in the history
  • Loading branch information
kbond committed Jun 30, 2022
1 parent a1e132f commit c74acc0
Show file tree
Hide file tree
Showing 4 changed files with 274 additions and 7 deletions.
44 changes: 42 additions & 2 deletions src/Attribute/Argument.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,67 @@
/**
* @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,
private ?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 (!$attribute = ($parameter->getAttributes(self::class)[0] ?? null)) {
return null;
}

/** @var self $value */
$value = $attribute->newInstance();
$value->name = $value->name ?? $parameter->name;

if ($value->mode) {
throw new \LogicException(); // todo
}

if ($value->default) {
throw new \LogicException(); // todo
}

$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];
}
}
46 changes: 44 additions & 2 deletions src/Attribute/Option.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,70 @@
/**
* @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,
private ?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 (!$attribute = ($parameter->getAttributes(self::class)[0] ?? null)) {
return null;
}

/** @var self $value */
$value = $attribute->newInstance();
$value->name = $value->name ?? $parameter->name;

if ($value->mode) {
throw new \LogicException(); // todo
}

if ($value->default) {
throw new \LogicException(); // todo
}

$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];
}
}
22 changes: 22 additions & 0 deletions src/ConfigureWithAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ trait ConfigureWithAttributes
{
protected function configure(): void
{
if (\PHP_VERSION_ID < 80000) {
throw new \LogicException(\sprintf('PHP 8+ required to use %s.', ConfigureWithAttributes::class));
}

$class = new \ReflectionClass($this);

foreach ($class->getAttributes(Argument::class) as $attribute) {
Expand All @@ -21,5 +25,23 @@ protected function configure(): void
foreach ($class->getAttributes(Option::class) as $attribute) {
$this->addOption(...$attribute->newInstance()->values());
}

try {
$parameters = (new \ReflectionClass(static::class))->getMethod('__invoke')->getParameters();
} catch (\ReflectionException $e) {
return; // not using Invokable
}

foreach ($parameters as $parameter) {
if ($args = Argument::parseParameter($parameter)) {
$this->addArgument(...$args);

continue;
}

if ($args = Option::parseParameter($parameter)) {
$this->addOption(...$args);
}
}
}
}
169 changes: 166 additions & 3 deletions tests/Unit/AttributesConfigureTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
use Zenstruck\Console\Attribute\Argument;
use Zenstruck\Console\Attribute\Option;
use Zenstruck\Console\ConfigureWithAttributes;
use Zenstruck\Console\Invokable;
use Zenstruck\Console\Test\TestCommand;

/**
* @author Kevin Bond <kevinbond@gmail.com>
Expand All @@ -19,10 +21,11 @@ final class AttributesConfigureTest extends TestCase
{
/**
* @test
* @dataProvider attributeCommandProvider
*/
public function parse_arguments_and_options(): void
public function parse_arguments_and_options(string $class): void
{
$command = new WithAttributesCommand();
$command = new $class();
$definition = $command->getDefinition();

$arg = $definition->getArgument('arg1');
Expand Down Expand Up @@ -77,6 +80,134 @@ public function parse_arguments_and_options(): void
$this->assertSame('o', $option->getShortcut());
$this->assertTrue($option->isValueRequired());
}

public static function attributeCommandProvider(): iterable
{
yield [WithClassAttributesCommand::class];
yield [WithParameterAttributesCommand::class];
}

/**
* @test
*/
public function argument_attribute_name_required_when_using_on_class(): void
{
$this->markTestIncomplete();
}

/**
* @test
*/
public function option_attribute_name_required_when_using_on_class(): void
{
$this->markTestIncomplete();
}

/**
* @test
*/
public function negatable_parameter_attribute_options(): void
{
$command = TestCommand::for(
new class() extends Command {
use ConfigureWithAttributes, Invokable;

public static function getDefaultName(): ?string
{
return 'command';
}

public function __invoke(#[Option] ?bool $foo): void
{
$this->io()->writeln(\sprintf('foo: %s', \var_export($foo, true)));
}
}
);

$command->execute()
->assertSuccessful()
->assertOutputContains('foo: NULL')
;

$command->execute('--foo')
->assertSuccessful()
->assertOutputContains('foo: true')
;

$command->execute('--no-foo')
->assertSuccessful()
->assertOutputContains('foo: false')
;
}

/**
* @test
*/
public function can_customize_argument_and_option_names_via_parameter_attribute(): void
{
$command = TestCommand::for(
new class() extends Command {
use ConfigureWithAttributes, Invokable;

public static function getDefaultName(): ?string
{
return 'command';
}

public function __invoke(
#[Argument('custom-foo')] ?string $foo,
#[Option('custom-bar')] bool $bar
): void {
$this->io()->writeln(\sprintf('foo: %s', \var_export($foo, true)));
$this->io()->writeln(\sprintf('bar: %s', \var_export($bar, true)));
}
}
);

$command->execute()
->assertSuccessful()
->assertOutputContains('foo: NULL')
->assertOutputContains('bar: false')
;

$command->execute('value --custom-bar')
->assertSuccessful()
->assertOutputContains('foo: value')
->assertOutputContains('bar: true')
;
}

/**
* @test
*/
public function cannot_use_mode_with_argument_as_parameter_attribute(): void
{
$this->markTestIncomplete();
}

/**
* @test
*/
public function cannot_use_default_with_argument_as_parameter_attribute(): void
{
$this->markTestIncomplete();
}

/**
* @test
*/
public function cannot_use_mode_with_option_as_parameter_attribute(): void
{
$this->markTestIncomplete();
}

/**
* @test
*/
public function cannot_use_default_with_option_as_parameter_attribute(): void
{
$this->markTestIncomplete();
}
}

#[Argument('arg1', InputArgument::REQUIRED, 'First argument is required')]
Expand All @@ -87,7 +218,39 @@ public function parse_arguments_and_options(): void
#[Option('option2', null, InputOption::VALUE_REQUIRED, 'Second option (value required)')]
#[Option('option3', null, InputOption::VALUE_REQUIRED, 'Third option with default value', 'default')]
#[Option('option4', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Fourth option is an array with a shortcut (-o)')]
class WithAttributesCommand extends Command
class WithClassAttributesCommand extends Command
{
use ConfigureWithAttributes;
}

class WithParameterAttributesCommand extends Command
{
use ConfigureWithAttributes, Invokable;

public function __invoke(
#[Argument(description: 'First argument is required')]
string $arg1,

#[Argument(description: 'Second argument is optional')]
?string $arg2 = null,

#[Argument(description: 'Third argument is optional with a default value')]
string $arg3 = 'default',

#[Argument('arg4', description: 'Fourth argument is an optional array')]
array $foo = [],

#[Option(description: 'First option (no value)')]
bool $option1 = false,

#[Option(description: 'Second option (value required)')]
?string $option2 = null,

#[Option(description: 'Third option with default value')]
string $option3 = 'default',

#[Option('option4', shortcut: 'o', description: 'Fourth option is an array with a shortcut (-o)')]
array $bar = []
) {
}
}

0 comments on commit c74acc0

Please sign in to comment.