diff --git a/.gitignore b/.gitignore index bef16f6..1aea17f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ phpunit.xml vendor build +.env tools .idea .phpunit.result.cache diff --git a/README.md b/README.md index 93669ff..0fd4f55 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Consolidation\AnnotatedCommand -Initialize Symfony Console commands from annotated command class methods. +Initialize Symfony Console commands from annotated/attributed command class methods. [![ci](https://github.com/consolidation/annotated-command/workflows/CI/badge.svg)](https://travis-ci.org/consolidation/annotated-command) [![scrutinizer](https://scrutinizer-ci.com/g/consolidation/annotated-command/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/consolidation/annotated-command/?branch=master) @@ -65,6 +65,24 @@ class MyCommandClass } ``` +or via PHP 8 attributes. + +```php + #[CLI\Name(name: 'my:echo', aliases: ['c'])] + #[CLI\Help(description: 'This is the my:echo command', synopsis: "This command will concatenate two parameters. If the --flip flag\nis provided, then the result is the concatenation of two and one.",)] + #[CLI\Param(name: 'one', description: 'The first parameter')] + #[CLI\Param(name: 'two', description: 'The other parameter')] + #[CLI\Option(name: 'flip', description: 'Whether or not the second parameter should come first in the result.')] + #[CLI\Usage(name: 'bet alpha --flip', description: 'Concatenate "alpha" and "bet".')] + public function myEcho($one, $two = '', array $options = ['flip' => false]) + { + if ($options['flip']) { + return "{$two}{$one}"; + } + return "{$one}{$two}"; + } +``` + ### Legacy Annotated Command Methods The legacy method for declaring commands is still supported. When using the legacy method, the command options, if any, are declared as the last parameter of the methods. The options will be passed in as an associative array; the default options of the last parameter should list the options recognized by the command. The rest of the parameters are arguments. Parameters with a default value are optional; those without a default value are required. ```php @@ -94,6 +112,7 @@ class MyCommandClass } } ``` +## Option Default Values The `$options` array must be an associative array whose key is the name of the option, and whose value is one of: diff --git a/composer.json b/composer.json index 1085108..0e681ec 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,7 @@ "cbf": "phpcbf --standard=PSR2 -n src", "unit": "SHELL_INTERACTIVE=true phpunit --colors=always", "lint": [ - "find src -name '*.php' -print0 | xargs -0 -n1 php -l", + "find src -name '*.php' -and ! -path 'src/Attributes/*' -print0 | xargs -0 -n1 php -l", "find tests/src -name '*.php' -and ! -name 'ExampleAttributesCommandFile.php' -print0 | xargs -0 -n1 php -l" ], "test": [ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e680109..b0fdf01 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -14,6 +14,9 @@ src + + src/Attributes + diff --git a/src/Attributes/Argument.php b/src/Attributes/Argument.php new file mode 100644 index 0000000..40d1d32 --- /dev/null +++ b/src/Attributes/Argument.php @@ -0,0 +1,28 @@ +getArguments(); + $commandInfo->addArgumentDescription($args['name'], @$args['description']); + } +} diff --git a/src/Attributes/Command.php b/src/Attributes/Command.php new file mode 100644 index 0000000..624d874 --- /dev/null +++ b/src/Attributes/Command.php @@ -0,0 +1,30 @@ +getArguments(); + $commandInfo->setName($args['name']); + $commandInfo->addAnnotation('command', $args['name']); + $commandInfo->setAliases($args['aliases'] ?? []); + } +} diff --git a/src/Attributes/DefaultFields.php b/src/Attributes/DefaultFields.php new file mode 100644 index 0000000..e3fa96f --- /dev/null +++ b/src/Attributes/DefaultFields.php @@ -0,0 +1,25 @@ +getArguments(); + $commandInfo->addAnnotation('default-fields', $args['fields']); + } +} diff --git a/src/Attributes/DefaultTableFields.php b/src/Attributes/DefaultTableFields.php new file mode 100644 index 0000000..3689320 --- /dev/null +++ b/src/Attributes/DefaultTableFields.php @@ -0,0 +1,25 @@ +getArguments(); + $commandInfo->addAnnotation('default-table-fields', $args['fields']); + } +} diff --git a/src/Attributes/FieldLabels.php b/src/Attributes/FieldLabels.php new file mode 100644 index 0000000..ddd82e5 --- /dev/null +++ b/src/Attributes/FieldLabels.php @@ -0,0 +1,25 @@ +getArguments(); + $commandInfo->addAnnotation('field-labels', $args['labels']); + } +} diff --git a/src/Attributes/FilterDefaultField.php b/src/Attributes/FilterDefaultField.php new file mode 100644 index 0000000..c37cc40 --- /dev/null +++ b/src/Attributes/FilterDefaultField.php @@ -0,0 +1,25 @@ +getArguments(); + $commandInfo->addAnnotation('filter-default-field', $args['field']); + } +} diff --git a/src/Attributes/Help.php b/src/Attributes/Help.php new file mode 100644 index 0000000..a457a1c --- /dev/null +++ b/src/Attributes/Help.php @@ -0,0 +1,33 @@ +getArguments(); + $commandInfo->setDescription($args['description']); + $commandInfo->setHelp(@$args['synopsis']); + $commandInfo->setHidden(@$args['hidden']); + } +} diff --git a/src/Attributes/Hook.php b/src/Attributes/Hook.php new file mode 100644 index 0000000..8dd39f5 --- /dev/null +++ b/src/Attributes/Hook.php @@ -0,0 +1,31 @@ +getArguments(); + $commandInfo->setName($args['target'] ?? ''); + $commandInfo->addAnnotation('hook', $args['type'] . ' ' . $args['target'] ?? ''); + } +} diff --git a/src/Attributes/Misc.php b/src/Attributes/Misc.php new file mode 100644 index 0000000..b7454ea --- /dev/null +++ b/src/Attributes/Misc.php @@ -0,0 +1,25 @@ +getArguments(); + $commandInfo->AddAnnotation(key($args['data']), current($args['data'])); + } +} diff --git a/src/Attributes/Option.php b/src/Attributes/Option.php new file mode 100644 index 0000000..5aaad99 --- /dev/null +++ b/src/Attributes/Option.php @@ -0,0 +1,28 @@ +getArguments(); + $commandInfo->addOptionDescription($args['name'], @$args['description']); + } +} diff --git a/src/Attributes/Topics.php b/src/Attributes/Topics.php new file mode 100644 index 0000000..6684914 --- /dev/null +++ b/src/Attributes/Topics.php @@ -0,0 +1,29 @@ +getArguments(); + $commandInfo->addAnnotation('topics', $args['topics'] ?? []); + $commandInfo->addAnnotation('topic', $args['is_topic'] ?? false); + } +} diff --git a/src/Attributes/Usage.php b/src/Attributes/Usage.php new file mode 100644 index 0000000..c64f9f2 --- /dev/null +++ b/src/Attributes/Usage.php @@ -0,0 +1,28 @@ +getArguments(); + $commandInfo->setExampleUsage($args['name'], @$args['description']); + } +} diff --git a/src/CommandLineAttributes.php b/src/CommandLineAttributes.php deleted file mode 100644 index 6d1a8c0..0000000 --- a/src/CommandLineAttributes.php +++ /dev/null @@ -1,9 +0,0 @@ -reflection); $this->docBlockIsParsed = true; + // Use method's return type if @return is not present. + if ($this->reflection->hasReturnType() && !$this->getReturnType()) { + $type = $this->reflection->getReturnType(); + if ($type instanceof \ReflectionUnionType) { + // Use first declared type. + $type = current($type->getTypes()); + } + $this->setReturnType($type->getName()); + } } } diff --git a/src/Parser/Internal/AttributesDocBlockParser.php b/src/Parser/Internal/AttributesDocBlockParser.php index 310f861..b0a8a8b 100644 --- a/src/Parser/Internal/AttributesDocBlockParser.php +++ b/src/Parser/Internal/AttributesDocBlockParser.php @@ -1,16 +1,15 @@ commandInfo = $commandInfo; $this->reflection = $reflection; + // @todo Unused. Lets just remove from this class? $this->fqcnCache = $fqcnCache ?: new FullyQualifiedClassCache(); } + /** + * Call the handle method of each attribute, which alters the CommandInfo object. + */ public function parse() { $attributes = $this->reflection->getAttributes(); foreach ($attributes as $attribute) { - if ($attribute->getName() === self::COMMAND_ATTRIBUTE_CLASS_NAME) { - foreach ($attribute->getArguments() as $argName => $argValue) { - switch ($argName) { - case 'name': - $this->commandInfo->setName($argValue); - break; - case 'description': - $this->commandInfo->setDescription($argValue); - break; - case 'help': - $this->commandInfo->setHelp($argValue); - break; - case 'aliases': - $this->commandInfo->setAliases($argValue); - break; - case 'usage': - $this->commandInfo->setExampleUsage(key($argValue), array_pop($argValue)); - break; - case 'options': - $set = $this->commandInfo->options(); - foreach ($argValue as $name => $option) { - $description = trim(preg_replace('#[ \t\n\r]+#', ' ', $option['description'])); - $this->commandInfo->addOptionDescription($name, $description); - } - break; - case 'params': - $set = $this->commandInfo->arguments(); - foreach ($argValue as $name => $param) { - $description = trim(preg_replace('#[ \t\n\r]+#', ' ', $param['description'])); - $this->commandInfo->addArgumentDescription($name, $description); - } - break; - default: - foreach ($argValue as $name => $annotation) { - foreach ($annotation as $value) { - $this->commandInfo->addAnnotation($name, $value); - } - } - } - } + if (method_exists($attribute->getName(), 'handle')) { + call_user_func([$attribute->getName(), 'handle'], $attribute, $this->commandInfo); } } } diff --git a/tests/AttributesCommandFactoryTest.php b/tests/AttributesCommandFactoryTest.php index 6ce1a22..be945b9 100644 --- a/tests/AttributesCommandFactoryTest.php +++ b/tests/AttributesCommandFactoryTest.php @@ -2,6 +2,7 @@ namespace Consolidation\AnnotatedCommand; use Consolidation\AnnotatedCommand\Options\AlterOptionsCommandEvent; +use Consolidation\OutputFormatters\StructuredData\RowsOfFields; use Symfony\Component\Console\Application; use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\BufferedOutput; @@ -13,13 +14,9 @@ class AttributesCommandFactoryTest extends TestCase protected $commandFileInstance; protected $commandFactory; - function setUp(): void - { - if (version_compare(PHP_VERSION, '8.0.0') === -1) { - $this->markTestSkipped('Attribute parsing requires PHP version 8.x.'); - } - } - + /** + * @requires PHP >= 8.0 + */ function testMyEchoCommand() { $this->commandFileInstance = new \Consolidation\TestUtils\ExampleAttributesCommandFile; @@ -40,6 +37,9 @@ function testMyEchoCommand() $this->assertRunCommandViaApplicationEquals($command, $input, 'alphabet'); } + /** + * @requires PHP >= 8.0 + */ function testImprovedEchoCommand() { $this->commandFileInstance = new \Consolidation\TestUtils\ExampleAttributesCommandFile; @@ -60,6 +60,18 @@ function testImprovedEchoCommand() $this->assertRunCommandViaApplicationEquals($command, $input, 'this that and the other thing'); } + /** + * @requires PHP >= 8.0 + */ + function testBirdsCommand() + { + $this->commandFileInstance = new \Consolidation\TestUtils\ExampleAttributesCommandFile; + $this->commandFactory = new AnnotatedCommandFactory(); + $commandInfo = $this->commandFactory->createCommandInfo($this->commandFileInstance, 'birds'); + $command = $this->commandFactory->createCommand($commandInfo, $this->commandFileInstance); + $this->assertEquals(RowsOfFields::class, $command->getReturnType()); + } + function assertRunCommandViaApplicationEquals($command, $input, $expectedOutput, $expectedStatusCode = 0) { list($statusCode, $commandOutput) = $this->runCommandViaApplication($command, $input); diff --git a/tests/CommandInfoTest.php b/tests/CommandInfoTest.php index d9b31fa..1d00a81 100644 --- a/tests/CommandInfoTest.php +++ b/tests/CommandInfoTest.php @@ -49,11 +49,11 @@ function testWithConfigImport() ); } + /** + * @requires PHP >= 8.0 + */ function testWithAttributes() { - if (version_compare(PHP_VERSION, '8.0.0') === -1) { - $this->markTestSkipped('Attribute parsing requires PHP version 8.x.'); - } $commandInfo = CommandInfo::create('\Consolidation\TestUtils\ExampleAttributesCommandFile', 'testArithmatic'); $this->assertCommandInfoIsAsExpected($commandInfo); } diff --git a/tests/src/ExampleAttributesCommandFile.php b/tests/src/ExampleAttributesCommandFile.php index 3d9dbf6..529f490 100644 --- a/tests/src/ExampleAttributesCommandFile.php +++ b/tests/src/ExampleAttributesCommandFile.php @@ -1,7 +1,9 @@ output = $output; } - #[CommandLineAttributes( - name: 'my:echo', - description: 'This is the my:echo command', - help: "This command will concatenate two parameters. If the --flip flag\nis provided, then the result is the concatenation of two and one.", - aliases: ['c'], - usage: ['bet alpha --flip' => 'Concatenate "alpha" and "bet".'], - options: [ - 'flip' => [ - 'description' => 'Whether or not the second parameter should come first in the result. Default: false' - ] - ] - )] + #[CLI\Command(name: 'my:echo', aliases: ['c'])] + #[CLI\Help(description: 'This is the my:echo command', synopsis: "This command will concatenate two parameters. If the --flip flag\nis provided, then the result is the concatenation of two and one.",)] + #[CLI\Argument(name: 'one', description: 'The first parameter')] + #[CLI\Argument(name: 'two', description: 'The other parameter')] + #[CLI\Option(name: 'flip', description: 'Whether or not the second parameter should come first in the result.')] + #[CLI\Usage(name: 'bet alpha --flip', description: 'Concatenate "alpha" and "bet".')] public function myEcho($one, $two = '', array $options = ['flip' => false]) { if ($options['flip']) { @@ -46,18 +42,11 @@ public function myEcho($one, $two = '', array $options = ['flip' => false]) return "{$one}{$two}"; } - #[CommandLineAttributes( - name: 'improved:echo', - description: 'This is the improved:echo command', - help: "This command will concatenate two parameters. If the --flip flag\nis provided, then the result is the concatenation of two and one.", - aliases: ['c'], - usage: ['bet alpha --flip' => 'Concatenate "alpha" and "bet".'], - options: [ - 'flip' => [ - 'description' => 'Whether or not the second parameter should come first in the result. Default: false' - ] - ] - )] + #[CLI\Command(name: 'improved:echo', aliases: ['c'])] + #[CLI\Help(description: 'This is the improved:echo command', synopsis: "This command will concatenate two parameters. If the --flip flag\nis provided, then the result is the concatenation of two and one.",)] + #[CLI\Argument(name: 'args', description: 'Any number of arguments separated by spaces.')] + #[CLI\Option(name: 'flip', description: 'Whether or not the second parameter should come first in the result.')] + #[CLI\Usage(name: 'bet alpha --flip', description: 'Concatenate "alpha" and "bet".')] public function improvedEcho(array $args, $flip = false) { if ($flip) { @@ -66,21 +55,13 @@ public function improvedEcho(array $args, $flip = false) return implode(' ', $args); } - #[CommandLineAttributes( - name: 'test:arithmatic', - description: 'This is the test:arithmatic command', - help: "This command will add one and two. If the --negate flag\nis provided, then the result is negated.", - aliases: ['arithmatic'], - usage: ['2 2 --negate' => 'Add two plus two and then negate.'], - options: [ - 'negate' => ['description' => 'Whether or not the result should be negated. Default: false'] - ], - params: [ - 'one' => ['description' => 'The first number to add.'], - 'two' => ['description' => 'The other number to add. Default: 2'] - ], - custom: ['dup' => ['one', 'two']] - )] + #[CLI\Command(name: 'test:arithmatic', aliases: ['arithmatic'])] + #[CLI\Help(description: 'This is the test:arithmatic command', synopsis: "This command will add one and two. If the --negate flag\nis provided, then the result is negated.",)] + #[CLI\Argument(name: 'one', description: 'The first number to add.')] + #[CLI\Argument(name: 'two', description: 'The other number to add.')] + #[CLI\Option(name: 'negate', description: 'Whether or not the result should be negated.')] + #[CLI\Usage(name: '2 2 --negate', description: 'Add two plus two and then negate.')] + #[CLI\Misc(data: ['dup' => ['one', 'two']])] public function testArithmatic($one, $two = 2, array $options = ['negate' => false, 'unused' => 'bob']) { $result = $one + $two; @@ -92,4 +73,25 @@ public function testArithmatic($one, $two = 2, array $options = ['negate' => fal // return a the result as a string so that it will be printed. return "$result"; } + + // Declare a hook with a target. + #[CLI\Hook(type: HookManager::POST_COMMAND_HOOK, target: 'test:arithmatic')] + #[CLI\Help(description: 'Add a text after test:arithmatic command')] + public function postArithmatic() + { + $this->output->writeln('HOOKED'); + } + + // Exercise table formatter options and Union return type. + #[CLI\Command(name: 'birds')] + #[CLI\FieldLabels(labels: ['name' => 'Name', 'color' => 'Color'])] + #[CLI\DefaultFields(fields: ['color'])] + public function birds(): RowsOfFields|int + { + $rows = [ + ['Bluebird' => 'blue'], + ['Cardinal' => 'red'], + ]; + return new RowsOfFields($rows); + } }