├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── example └── git.php ├── phpbench.json ├── phpstan.neon ├── phpunit.xml └── src ├── Application ├── Application.php ├── CommandMiddleware.php ├── Context.php ├── Exception │ ├── CommandHandlerNotFound.php │ └── MiddlewareExhausted.php ├── ExceptionContext.php ├── ExceptionHandler.php ├── Handler.php ├── Middleware.php └── Middleware │ ├── ClosureMiddleware.php │ └── HelpMiddleware.php ├── ApplicationBuilder.php ├── Attribute ├── App.php ├── Arg.php ├── Cmd.php └── Opt.php ├── Error ├── ParseError.php └── ParseErrorWithContext.php ├── Metadata ├── AbstractCommandDefinition.php ├── ApplicationDefinition.php ├── ArgumentDefinition.php ├── ArgumentDefinitions.php ├── CommandDefinition.php ├── CommandDefinitions.php ├── Loader.php ├── OptionDefinition.php └── OptionDefinitions.php ├── Parser └── Parser.php ├── Printer └── AsciiPrinter.php └── Type ├── BooleanType.php ├── FloatType.php ├── IntegerType.php ├── ListType.php ├── MixedType.php ├── StringType.php ├── Type.php ├── TypeFactory.php └── UnionType.php /.gitattributes: -------------------------------------------------------------------------------- 1 | *.php diff=php 2 | /tests export-ignore 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - 'main' 8 | 9 | env: 10 | fail-fast: true 11 | TZ: "Europe/Paris" 12 | 13 | jobs: 14 | phpunit: 15 | name: "PHPUnit (${{ matrix.php-version }})" 16 | 17 | runs-on: "ubuntu-latest" 18 | 19 | strategy: 20 | matrix: 21 | php-version: 22 | - '8.1' 23 | - '8.2' 24 | 25 | steps: 26 | - 27 | name: "Checkout code" 28 | uses: "actions/checkout@v2" 29 | - 30 | name: "Install PHP" 31 | uses: "shivammathur/setup-php@v2" 32 | with: 33 | coverage: "none" 34 | extensions: "${{ env.REQUIRED_PHP_EXTENSIONS }}" 35 | php-version: "${{ matrix.php-version }}" 36 | tools: composer:v2 37 | 38 | - 39 | name: "Composer install" 40 | uses: "ramsey/composer-install@v1" 41 | with: 42 | composer-options: "--no-scripts" 43 | - 44 | name: "Run PHPUnit" 45 | run: "php -dzend.assertions=1 vendor/bin/phpunit" 46 | phpstan: 47 | name: "PHPStan (${{ matrix.php-version }})" 48 | 49 | runs-on: "ubuntu-latest" 50 | 51 | strategy: 52 | matrix: 53 | php-version: 54 | - '8.1' 55 | 56 | steps: 57 | - 58 | name: "Checkout code" 59 | uses: "actions/checkout@v2" 60 | - 61 | name: "Install PHP" 62 | uses: "shivammathur/setup-php@v2" 63 | with: 64 | coverage: "none" 65 | extensions: "${{ env.REQUIRED_PHP_EXTENSIONS }}" 66 | php-version: "${{ matrix.php-version }}" 67 | tools: composer:v2 68 | 69 | - 70 | name: "Composer install" 71 | uses: "ramsey/composer-install@v1" 72 | with: 73 | composer-options: "--no-scripts" 74 | - 75 | name: "Run PHPStan" 76 | run: "vendor/bin/phpstan analyse" 77 | php-cs-fixer: 78 | name: "PHP-CS-Fixer (${{ matrix.php-version }})" 79 | 80 | runs-on: "ubuntu-latest" 81 | 82 | strategy: 83 | matrix: 84 | php-version: 85 | - '8.1' 86 | steps: 87 | - 88 | name: "Checkout code" 89 | uses: "actions/checkout@v2" 90 | - 91 | name: "Install PHP" 92 | uses: "shivammathur/setup-php@v2" 93 | with: 94 | coverage: "none" 95 | extensions: "${{ env.REQUIRED_PHP_EXTENSIONS }}" 96 | php-version: "${{ matrix.php-version }}" 97 | tools: composer:v2 98 | 99 | - 100 | name: "Composer install" 101 | uses: "ramsey/composer-install@v1" 102 | with: 103 | composer-options: "--no-scripts" 104 | - 105 | name: "Run PHP-CS_Fixer" 106 | run: "PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --diff" 107 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock 3 | /.phpunit.cache 4 | /.php-cs-fixer.cache 5 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in('src') 8 | ->in('tests') 9 | ; 10 | 11 | return (new Config()) 12 | ->setRiskyAllowed(true) 13 | ->setRules([ 14 | '@PSR2' => true, 15 | 'final_class' => true, 16 | 'no_unused_imports' => true, 17 | 'phpdoc_to_property_type' => true, 18 | 'no_superfluous_phpdoc_tags' => [ 19 | 'remove_inheritdoc' => true, 20 | 'allow_mixed' => true, 21 | ], 22 | 'class_attributes_separation' => [ 23 | 'elements' => [ 24 | 'const' => 'only_if_meta', 25 | 'property' => 'one', 26 | 'trait_import' => 'only_if_meta', 27 | ], 28 | ], 29 | 'ordered_class_elements' => true, 30 | 'no_empty_phpdoc' => true, 31 | 'phpdoc_trim' => true, 32 | 'array_syntax' => ['syntax' => 'short'], 33 | 'list_syntax' => ['syntax' => 'short'], 34 | 'void_return' => true, 35 | 'ordered_class_elements' => true, 36 | 'single_quote' => true, 37 | 'heredoc_indentation' => true, 38 | 'global_namespace_import' => true, 39 | 'no_trailing_whitespace' => true, 40 | 'no_whitespace_in_blank_line' => true, 41 | ]) 42 | ->setFinder($finder) 43 | ; 44 | 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Daniel Leech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | CLI Parser and Handler 2 | ====================== 3 | 4 | > warning: don't usr this, it's a failed experiment! 5 | 6 | Type safe CLI parser and command handler for PHP inspired by [Kong](https://github.com/alecthomas/kong). 7 | 8 | Features 9 | -------- 10 | 11 | - Casts arguments and options into objects. 12 | - Command bus and middleware architecture. 13 | - Parsing 14 | - Command nesting 15 | - Type support for `string`, `int`, `float`, `boolean` and `lists`. 16 | - Repeated arguments 17 | - Optional arguments 18 | - Long and short options 19 | - Boolean options (flags) 20 | - List options 21 | 22 | Usage Example 23 | ------------- 24 | 25 | ```php 26 | init = new InitCmd(); 77 | $cli->clone = new CloneCmd(); 78 | 79 | $application = ApplicationBuilder::fromSpecification($cli) 80 | ->prependMiddleware(new HelpMiddleware(new AsciiPrinter())) 81 | ->addHandler(InitCmd::class, function (Context $ctx) { 82 | echo 'Initializing' . "\n"; 83 | return 0; 84 | }) 85 | ->addHandler(CloneCmd::class, function (Context $ctx) { 86 | echo sprintf('Git cloning %s...', $ctx->command()->repo) . "\n"; 87 | if ($ctx->command()->recurseSubModules) { 88 | echo '... and recursing submodules' . "\n"; 89 | } 90 | return 0; 91 | }) 92 | ->build(); 93 | 94 | exit($application->run($argv)); 95 | ``` 96 | 97 | Contribution 98 | ------------ 99 | 100 | Contribute! 101 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "php-tui/cli-parser", 3 | "description": "Type-safe CLI parser", 4 | "type": "library", 5 | "license": "MIT", 6 | "autoload": { 7 | "psr-4": { 8 | "PhpTui\\CliParser\\": "src/" 9 | } 10 | }, 11 | "autoload-dev": { 12 | "psr-4": { 13 | "PhpTui\\CliParser\\Tests\\": "tests/" 14 | } 15 | }, 16 | "authors": [ 17 | { 18 | "name": "Daniel Leech" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.1" 23 | }, 24 | "require-dev": { 25 | "friendsofphp/php-cs-fixer": "^3.34", 26 | "phpstan/phpstan": "^1.10", 27 | "phpunit/phpunit": "^10.4", 28 | "symfony/var-dumper": "^6.3" 29 | }, 30 | "scripts": { 31 | "phpstan": "./vendor/bin/phpstan --memory-limit=1G", 32 | "php-cs-fixer": "./vendor/bin/php-cs-fixer fix", 33 | "phpunit": "./vendor/bin/phpunit", 34 | "integrate": [ 35 | "@php-cs-fixer", 36 | "@phpstan", 37 | "@phpunit" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /example/git.php: -------------------------------------------------------------------------------- 1 | init = new InitCmd(); 54 | $cli->clone = new CloneCmd(); 55 | 56 | $application = ApplicationBuilder::fromSpecification($cli) 57 | ->prependMiddleware(new HelpMiddleware(new AsciiPrinter())) 58 | ->addHandler(GitCmd::class, function (Context $ctx) { 59 | return 0; 60 | }) 61 | ->addHandler(InitCmd::class, function (Context $ctx) { 62 | dump($ctx->application()); 63 | return 0; 64 | }) 65 | ->addHandler(CloneCmd::class, function ( 66 | Context $ctx 67 | ) { 68 | println(sprintf('Git cloning %s...', $ctx->command()->repo)); 69 | if ($ctx->command()->recurseSubModules) { 70 | println('... and recursing submodules'); 71 | } 72 | return 0; 73 | }) 74 | ->build(); 75 | 76 | exit($application->run($argv)); 77 | 78 | function println(string $message): void 79 | { 80 | echo $message ."\n"; 81 | } 82 | -------------------------------------------------------------------------------- /phpbench.json: -------------------------------------------------------------------------------- 1 | { 2 | "runner.path": "tests/Benchmark", 3 | "$schema": "vendor/phpbench/phpbench/phpbench.schema.json", 4 | "runner.bootstrap": "vendor/autoload.php" 5 | } 6 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - src 5 | - tests 6 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | tests 19 | 20 | 21 | 22 | 23 | 24 | src 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Application/Application.php: -------------------------------------------------------------------------------- 1 | $argv 22 | */ 23 | public function run(array $argv): int 24 | { 25 | $applicationDefinition = $this->loader->load($this->cli); 26 | 27 | array_shift($argv); 28 | 29 | try { 30 | [$commandDefinition, $command] = $this->parser->parse( 31 | $applicationDefinition, 32 | $this->cli, 33 | $argv 34 | ); 35 | } catch (Throwable $exception) { 36 | return $this->exceptionHandler->handle( 37 | new ExceptionContext($applicationDefinition, $exception) 38 | ); 39 | } 40 | 41 | return $this->handler->handle(new Context( 42 | $applicationDefinition, 43 | $commandDefinition, 44 | $this->cli, 45 | $command 46 | )); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Application/CommandMiddleware.php: -------------------------------------------------------------------------------- 1 | ):int> $handlers 15 | */ 16 | public function __construct(private array $handlers) 17 | { 18 | } 19 | /** 20 | * @param Context $context 21 | */ 22 | public function handle(Handler $handler, Context $context): int 23 | { 24 | if (!isset($this->handlers[$context->command()::class])) { 25 | throw new CommandHandlerNotFound($context->command()::class); 26 | } 27 | 28 | return ($this->handlers[$context->command()::class])($context); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Application/Context.php: -------------------------------------------------------------------------------- 1 | applicationDefinition; 27 | } 28 | 29 | public function commandDefinition(): AbstractCommandDefinition 30 | { 31 | return $this->commandDefinition; 32 | } 33 | 34 | /** 35 | * @return TApplication 36 | */ 37 | public function application(): object 38 | { 39 | return $this->application; 40 | } 41 | 42 | /** 43 | * @return TCommand 44 | */ 45 | public function command(): object 46 | { 47 | return $this->command; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Application/Exception/CommandHandlerNotFound.php: -------------------------------------------------------------------------------- 1 | printer->print($exceptionContext); 16 | return 127; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Application/Handler.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | private array $middlewares; 13 | 14 | public function __construct(Middleware ...$middlewares) 15 | { 16 | $this->middlewares = array_values($middlewares); 17 | } 18 | /** 19 | * @param Context $context 20 | */ 21 | public function handle(Context $context): int 22 | { 23 | $middleware = array_shift($this->middlewares); 24 | 25 | if (!$middleware) { 26 | throw new MiddlewareExhausted( 27 | 'Middleware exhausted (no middleware handled the request)' 28 | ); 29 | } 30 | 31 | return $middleware->handle($this, $context); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Application/Middleware.php: -------------------------------------------------------------------------------- 1 | $context 9 | */ 10 | public function handle(Handler $handler, Context $context): int; 11 | } 12 | -------------------------------------------------------------------------------- /src/Application/Middleware/ClosureMiddleware.php: -------------------------------------------------------------------------------- 1 | closure)($handler, $context); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Application/Middleware/HelpMiddleware.php: -------------------------------------------------------------------------------- 1 | $context 20 | */ 21 | public function handle(Handler $handler, Context $context): int 22 | { 23 | $definition = $context->applicationDefinition(); 24 | $option = $definition->options()->getOrNull($this->helpOption); 25 | 26 | if (null === $option) { 27 | throw new RuntimeException(sprintf( 28 | 'In order to use the %s middleware you must add a `%s` property to your %s', 29 | HelpMiddleware::class, 30 | $this->helpOption, 31 | $context->application()::class 32 | )); 33 | } 34 | 35 | if (!$option->type instanceof BooleanType) { 36 | throw new RuntimeException(sprintf( 37 | '"help" property must be a boolean type in order to use the %s middleware', 38 | HelpMiddleware::class 39 | )); 40 | } 41 | 42 | $value = $context->application()->{$this->helpOption}; 43 | if (!$value) { 44 | return $handler->handle($context); 45 | } 46 | 47 | echo $this->printer->print($context->commandDefinition()); 48 | 49 | return 0; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ApplicationBuilder.php: -------------------------------------------------------------------------------- 1 | ,callable(Context):int> 23 | */ 24 | private array $handlers = []; 25 | 26 | /** 27 | * @var Middleware[] 28 | */ 29 | private array $prependMiddlewares = []; 30 | 31 | /** 32 | * @param TApplication $cli 33 | */ 34 | public function __construct( 35 | private object $cli 36 | ) { 37 | } 38 | 39 | /** 40 | * @template TCli of object 41 | * @param TCli $cli 42 | * @return self 43 | */ 44 | public static function fromSpecification(object $cli): self 45 | { 46 | return new self($cli); 47 | } 48 | /** 49 | * @template TCmd of object 50 | * @param class-string $cmdFqn 51 | * @param callable(Context):int $handler 52 | * @return self 53 | */ 54 | public function addHandler(string $cmdFqn, callable $handler): self 55 | { 56 | /** @phpstan-ignore-next-line */ 57 | $this->handlers[$cmdFqn] = $handler; 58 | return $this; 59 | } 60 | 61 | /** 62 | * @return self 63 | */ 64 | public function prependMiddleware(Middleware ...$middlewares): self 65 | { 66 | $this->prependMiddlewares = array_merge( 67 | $this->prependMiddlewares, 68 | $middlewares 69 | ); 70 | return $this; 71 | } 72 | 73 | public function build(): Application 74 | { 75 | return new Application( 76 | $this->cli, 77 | new Loader(), 78 | new Parser(), 79 | new Handler(...[ 80 | ...$this->prependMiddlewares, 81 | new CommandMiddleware($this->handlers) 82 | ]), 83 | new ExceptionHandler(new AsciiPrinter()), 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Attribute/App.php: -------------------------------------------------------------------------------- 1 | options = $options ?: new OptionDefinitions([]); 21 | $this->arguments = $arguments ?: new ArgumentDefinitions([]); 22 | $this->commands = $commands ?: new CommandDefinitions([]); 23 | } 24 | 25 | public function commands(): CommandDefinitions 26 | { 27 | return $this->commands; 28 | } 29 | 30 | public function arguments(): ArgumentDefinitions 31 | { 32 | return $this->arguments; 33 | } 34 | 35 | public function options(): OptionDefinitions 36 | { 37 | return $this->options; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Metadata/ApplicationDefinition.php: -------------------------------------------------------------------------------- 1 | $type 12 | */ 13 | public function __construct( 14 | public string $name, 15 | public Type $type, 16 | public ?string $help = null, 17 | public bool $required = true 18 | ) { 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/Metadata/ArgumentDefinitions.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class ArgumentDefinitions implements IteratorAggregate, Countable 14 | { 15 | /** 16 | * @param ArgumentDefinition[] $arguments 17 | */ 18 | public function __construct(private readonly array $arguments) 19 | { 20 | } 21 | 22 | public function getArgument(string $name):?ArgumentDefinition 23 | { 24 | foreach ($this->arguments as $argument) { 25 | if ($argument->name === $name) { 26 | return $argument; 27 | } 28 | } 29 | 30 | return null; 31 | } 32 | 33 | public function getIterator(): Traversable 34 | { 35 | return new ArrayIterator($this->arguments); 36 | } 37 | 38 | public function count(): int 39 | { 40 | return count($this->arguments); 41 | } 42 | 43 | /** 44 | * @return ArgumentDefinition[] 45 | */ 46 | public function toArray(): array 47 | { 48 | return $this->arguments; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Metadata/CommandDefinition.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class CommandDefinitions implements IteratorAggregate, Countable 14 | { 15 | /** 16 | * @param CommandDefinition[] $commands 17 | */ 18 | public function __construct(private array $commands) 19 | { 20 | } 21 | 22 | public function getCommand(string $name):?CommandDefinition 23 | { 24 | foreach ($this->commands as $command) { 25 | if ($command->name === $name) { 26 | return $command; 27 | } 28 | } 29 | 30 | return null; 31 | } 32 | 33 | public function getIterator(): Traversable 34 | { 35 | return new ArrayIterator($this->commands); 36 | } 37 | 38 | public function count(): int 39 | { 40 | return count($this->commands); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Metadata/Loader.php: -------------------------------------------------------------------------------- 1 | loadCommand($object, null); 24 | if (!$cmd instanceof ApplicationDefinition) { 25 | throw new RuntimeException('Did not parse an application definition'); 26 | } 27 | $this->validate($cmd); 28 | return $cmd; 29 | } 30 | 31 | /** 32 | * @return ($parent is null ? ApplicationDefinition : CommandDefinition) 33 | */ 34 | private function loadCommand(object $object, ?ReflectionProperty $parent): AbstractCommandDefinition 35 | { 36 | $reflection = new ReflectionObject($object); 37 | 38 | $args = []; 39 | $cmds = []; 40 | $options = []; 41 | $name = $parent?->getName(); 42 | $help = null; 43 | $author = null; 44 | $version = null; 45 | 46 | foreach ($reflection->getAttributes(Cmd::class) as $attribute) { 47 | $cmd = $attribute->newInstance(); 48 | $name = $cmd->name ?? $name; 49 | $help = $cmd->help; 50 | } 51 | foreach ($reflection->getAttributes(App::class) as $attribute) { 52 | $app = $attribute->newInstance(); 53 | $name = $app->name; 54 | $help = $app->help; 55 | $author = $app->author; 56 | $version = $app->version; 57 | } 58 | 59 | foreach ($reflection->getProperties() as $property) { 60 | foreach ($property->getAttributes(Arg::class) as $arg) { 61 | $args[] = $this->loadArg($property, $arg); 62 | continue 2; 63 | } 64 | foreach ($property->getAttributes(Opt::class) as $opt) { 65 | $options[] = $this->loadOption($property, $opt); 66 | continue 2; 67 | } 68 | $subCmd = $property->getValue($object); 69 | if (is_object($subCmd)) { 70 | $cmds[] = $this->loadCommand($subCmd, $property); 71 | } 72 | } 73 | 74 | if ($parent) { 75 | return new CommandDefinition( 76 | name: $name ?? self::ROOT_NAME, 77 | propertyName: $parent->getName(), 78 | arguments: new ArgumentDefinitions($args), 79 | commands: new CommandDefinitions($cmds), 80 | options: new OptionDefinitions($options), 81 | help: $help, 82 | ); 83 | } 84 | 85 | return new ApplicationDefinition( 86 | name: $name ?? self::ROOT_NAME, 87 | arguments: new ArgumentDefinitions($args), 88 | commands: new CommandDefinitions($cmds), 89 | options: new OptionDefinitions($options), 90 | help: $help, 91 | version: $version, 92 | author: $author, 93 | ); 94 | } 95 | 96 | /** 97 | * @param ReflectionAttribute $arg 98 | */ 99 | private function loadArg(ReflectionProperty $property, ReflectionAttribute $arg): ArgumentDefinition 100 | { 101 | $attribute = $arg->newInstance(); 102 | $name = $property->getName(); 103 | $type = TypeFactory::fromReflectionType($property->getType()); 104 | 105 | if ($type instanceof ListType) { 106 | $type = new ListType(TypeFactory::fromString($attribute->type)); 107 | } 108 | 109 | return new ArgumentDefinition( 110 | name: $name, 111 | type: $type, 112 | help: $attribute->help, 113 | required: $attribute->required, 114 | ); 115 | } 116 | 117 | /** 118 | * @param ReflectionAttribute $opt 119 | */ 120 | private function loadOption(ReflectionProperty $property, ReflectionAttribute $opt): OptionDefinition 121 | { 122 | $attribute = $opt->newInstance(); 123 | $parseName = self::resolveName($property->getName(), $attribute); 124 | $type = TypeFactory::fromReflectionType($property->getType()); 125 | if ($type instanceof ListType) { 126 | $type = new ListType(TypeFactory::fromString($attribute->type)); 127 | } 128 | return new OptionDefinition( 129 | name: $property->getName(), 130 | short: $this->parseShortName($attribute->short), 131 | type: $type, 132 | parseName: $parseName, 133 | help: $attribute->help, 134 | ); 135 | } 136 | 137 | private static function resolveName(string $string, Opt|Arg $opt): string 138 | { 139 | if ($opt->name !== null) { 140 | return $opt->name; 141 | } 142 | 143 | return $string; 144 | } 145 | 146 | private function parseShortName(?string $name): ?string 147 | { 148 | if (null === $name) { 149 | return null; 150 | } 151 | 152 | if (strlen($name) !== 1) { 153 | throw new RuntimeException(sprintf( 154 | 'Short name must be 1 character long, got "%s"', 155 | $name 156 | )); 157 | } 158 | 159 | return $name; 160 | } 161 | 162 | private function validate(AbstractCommandDefinition $cmd): void 163 | { 164 | $firstOptional = null; 165 | foreach ($cmd->arguments() as $argument) { 166 | if ($firstOptional && $argument->required) { 167 | throw new ParseErrorWithContext(sprintf( 168 | 'Required argument <%s> cannot be positioned after optional argument <%s>', 169 | $argument->name, 170 | $firstOptional->name, 171 | ), $cmd); 172 | } 173 | if ($argument->required === false) { 174 | $firstOptional = $argument; 175 | } 176 | 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/Metadata/OptionDefinition.php: -------------------------------------------------------------------------------- 1 | $type 14 | */ 15 | public function __construct( 16 | public readonly string $name, 17 | public readonly Type $type, 18 | ?string $parseName = null, 19 | public readonly ?string $short = null, 20 | public readonly ?string $help = null 21 | ) { 22 | $this->parseName = $parseName ?: $name; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/Metadata/OptionDefinitions.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class OptionDefinitions implements IteratorAggregate, Countable 15 | { 16 | /** 17 | * @var array 18 | */ 19 | private readonly array $optionsByName; 20 | 21 | /** 22 | * @var array 23 | */ 24 | private readonly array $optionsByShort; 25 | 26 | /** 27 | * @var OptionDefinition[] 28 | */ 29 | private readonly array $options; 30 | 31 | /** 32 | * @param OptionDefinition[] $options 33 | */ 34 | public function __construct(array $options) 35 | { 36 | $optionsByName = []; 37 | $optionsByShort = []; 38 | foreach ($options as $option) { 39 | $optionsByName[$option->parseName] = $option; 40 | if ($option->short !== null) { 41 | $optionsByShort[$option->short] = $option; 42 | } 43 | } 44 | $this->optionsByName = $optionsByName; 45 | $this->optionsByShort = $optionsByShort; 46 | $this->options = $options; 47 | } 48 | 49 | public function getOrNull(string $name): ?OptionDefinition 50 | { 51 | if (isset($this->optionsByName[$name])) { 52 | return $this->optionsByName[$name]; 53 | } 54 | 55 | return null; 56 | } 57 | 58 | public function get(string $name): OptionDefinition 59 | { 60 | $option = $this->getOrNull($name); 61 | 62 | if ($option) { 63 | return $this->optionsByName[$name]; 64 | } 65 | 66 | throw new ParseError(sprintf( 67 | 'Unknown option --%s, known options: %s', 68 | $name, 69 | implode(', ', array_keys($this->optionsByName)) 70 | )); 71 | } 72 | 73 | public function shortOption(string $name): OptionDefinition 74 | { 75 | if (isset($this->optionsByShort[$name])) { 76 | return $this->optionsByShort[$name]; 77 | } 78 | 79 | throw new ParseError(sprintf( 80 | 'Unknown short option -%s, known options: %s', 81 | $name, 82 | implode(', ', array_keys($this->optionsByShort)) 83 | )); 84 | } 85 | 86 | public function getIterator(): Traversable 87 | { 88 | return new ArrayIterator($this->options); 89 | } 90 | 91 | public function count(): int 92 | { 93 | return count($this->options); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Parser/Parser.php: -------------------------------------------------------------------------------- 1 | parseCommand($target, $definition, $args); 31 | return $cmd; 32 | } 33 | 34 | /** 35 | * @param list $args 36 | * @return array{AbstractCommandDefinition,object} 37 | */ 38 | public function parseCommand(object $target, AbstractCommandDefinition $commandDefinition, array $args): array 39 | { 40 | try { 41 | return $this->doParseCommand($target, $commandDefinition, $args); 42 | } catch (ParseError $notFound) { 43 | throw new ParseErrorWithContext($notFound->getMessage(), $commandDefinition, $notFound); 44 | } 45 | } 46 | 47 | /** 48 | * @param list $args 49 | * @return array{AbstractCommandDefinition,object} 50 | */ 51 | public function doParseCommand(object $target, AbstractCommandDefinition $commandDefinition, array $args): array 52 | { 53 | $argumentDefinitions = $commandDefinition->arguments()->toArray(); 54 | $commandDefinitions = $commandDefinition->commands(); 55 | 56 | $longOptions = []; 57 | 58 | while ($arg = array_shift($args)) { 59 | $parsed = $this->parseArgument($arg); 60 | 61 | $type = $parsed[0]; 62 | $value = $parsed[1]; 63 | $name = $parsed[2] ?? null; 64 | 65 | if ($type === self::T_ARG) { 66 | [$newCommandDefinition, $newTarget] = $this->mapArgument( 67 | $commandDefinition, 68 | $target, 69 | $args, 70 | $argumentDefinitions, 71 | $arg 72 | ); 73 | if ($newTarget !== $target) { 74 | return [$newCommandDefinition, $newTarget]; 75 | } 76 | continue; 77 | } 78 | 79 | if ($type === self::T_OPT_FLAG) { 80 | $this->mapFlag($commandDefinition, $target, $value); 81 | continue; 82 | } 83 | if ($type === self::T_OPT) { 84 | $this->mapOption($commandDefinition, $target, $name ?? '', $value); 85 | continue; 86 | } 87 | if ($type === self::T_OPT_SHORT) { 88 | $this->mapShortOption($commandDefinition, $target, $name ?? '', $value); 89 | continue; 90 | } 91 | if ($type === self::T_OPT_SHORT_FLAG) { 92 | $this->mapShortOptionFlag($commandDefinition, $target, $name ?? ''); 93 | continue; 94 | } 95 | 96 | /** @phpstan-ignore-next-line */ 97 | throw new RuntimeException(sprintf( 98 | 'Do not know how to map argument of type "%s"', 99 | $type 100 | )); 101 | } 102 | 103 | $requiredArguments = array_filter( 104 | $argumentDefinitions, 105 | fn (ArgumentDefinition $definition) => $definition->required, 106 | ); 107 | if (count($requiredArguments)) { 108 | throw new ParseErrorWithContext(sprintf( 109 | 'Missing required argument(s) <%s> in command "%s"', 110 | implode('>, <', array_map(fn (ArgumentDefinition $a) => $a->name, $requiredArguments)), 111 | $commandDefinition->name 112 | ), $commandDefinition); 113 | } 114 | 115 | return [$commandDefinition, $target]; 116 | } 117 | 118 | /** 119 | * @return array{0:self::T_*,1:(string),2?:string} 120 | */ 121 | private function parseArgument(string $arg): array 122 | { 123 | if (substr($arg, 0, 1) !== '-') { 124 | return [self::T_ARG, $arg]; 125 | } 126 | 127 | // long option 128 | if (substr($arg, 1, 1) === '-') { 129 | $equalPos = strpos($arg, '='); 130 | if ($equalPos !== false) { 131 | // option with value 132 | return [ 133 | self::T_OPT, 134 | substr($arg, strpos($arg, '=') + 1), 135 | substr($arg, 2, $equalPos - 2), 136 | ]; 137 | } 138 | 139 | // boolean flag 140 | return [ 141 | self::T_OPT_FLAG, 142 | substr($arg, 2) 143 | ]; 144 | } 145 | 146 | // short option 147 | $optionName = substr($arg, 1, 1); 148 | $optionValueString = substr($arg, 2) ?: null; 149 | if ($optionValueString == null) { 150 | return [self::T_OPT_SHORT_FLAG, '', $optionName]; 151 | } 152 | 153 | return [ 154 | self::T_OPT_SHORT, 155 | $optionValueString, 156 | $optionName, 157 | ]; 158 | } 159 | 160 | /** 161 | * @param ArgumentDefinition[] $argumentDefinitions 162 | * @param list $args 163 | * @return array{AbstractCommandDefinition,object} 164 | */ 165 | private function mapArgument( 166 | AbstractCommandDefinition $commandDefinition, 167 | object $target, 168 | array &$args, 169 | array &$argumentDefinitions, 170 | string $arg 171 | ): array { 172 | $argumentDefinition = array_shift($argumentDefinitions); 173 | 174 | if ($argumentDefinition instanceof ArgumentDefinition) { 175 | if ($argumentDefinition->type instanceof ListType) { 176 | $target->{$argumentDefinition->name} = array_map( 177 | fn (string $arg) => $argumentDefinition->type->itemType()->parse($arg), 178 | [$arg, ...$args] 179 | ); 180 | $args = []; 181 | 182 | return [$commandDefinition, $target]; 183 | } 184 | 185 | $target->{$argumentDefinition->name} = $argumentDefinition->type->parse($arg); 186 | 187 | return [$commandDefinition, $target]; 188 | } 189 | 190 | $subCommandDefinition = $commandDefinition->commands()->getCommand($arg); 191 | if (null !== $subCommandDefinition) { 192 | $this->parseCommand( 193 | $target->{$subCommandDefinition->propertyName}, 194 | $subCommandDefinition, 195 | $args 196 | ); 197 | return [$subCommandDefinition, $target->{$subCommandDefinition->propertyName}]; 198 | } 199 | throw new ParseErrorWithContext(sprintf( 200 | 'Extra argument with value "%s" provided for command <%s>', 201 | $arg, 202 | $commandDefinition->name 203 | ), $commandDefinition); 204 | } 205 | 206 | private function mapOption( 207 | AbstractCommandDefinition $commandDefinition, 208 | object $target, 209 | string $name, 210 | string $value 211 | ): void { 212 | $option = $commandDefinition->options()->get($name); 213 | if ($option->type instanceof ListType) { 214 | $target->{$option->name} = array_map( 215 | fn (string $arg) => $option->type->itemType()->parse($arg), 216 | explode(',', $value) 217 | ); 218 | return; 219 | } 220 | $target->{$option->name} = $option->type->parse($value); 221 | } 222 | 223 | private function mapShortOption(AbstractCommandDefinition $commandDefinition, object $target, string $name, string $value): void 224 | { 225 | $option = $commandDefinition->options()->shortOption($name); 226 | $target->{$option->name} = $option->type->parse($value); 227 | } 228 | 229 | private function mapFlag(AbstractCommandDefinition $commandDefinition, object $target, string $name): void 230 | { 231 | $option = $commandDefinition->options()->get($name); 232 | $target->{$option->name} = true; 233 | } 234 | 235 | private function mapShortOptionFlag(AbstractCommandDefinition $commandDefinition, object $target, string $name): void 236 | { 237 | $option = $commandDefinition->options()->shortOption($name); 238 | $target->{$option->name} = true; 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/Printer/AsciiPrinter.php: -------------------------------------------------------------------------------- 1 | printCommad($object); 18 | } 19 | if ($object instanceof ExceptionContext) { 20 | return $this->printException($object); 21 | } 22 | 23 | throw new RuntimeException(sprintf( 24 | 'Do not know how to print "%s"', 25 | $object::class 26 | )); 27 | } 28 | 29 | private function printCommad(AbstractCommandDefinition $object, int $level = 0): string 30 | { 31 | $out = []; 32 | 33 | if ($object->help) { 34 | $out[] = $object->help; 35 | $out[] = ''; 36 | } 37 | 38 | $out[] = $this->commandSynopsis($object, $level); 39 | $out[] = ''; 40 | 41 | if (count($object->options())) { 42 | $out[] = 'Options:'; 43 | $out[] = ''; 44 | foreach ($object->options() as $option) { 45 | $out[] = $this->optionSynopsis($option, $level+1); 46 | } 47 | $out[] = ''; 48 | } 49 | 50 | if (count($object->commands())) { 51 | $out[] = 'Commands:'; 52 | $out[] = ''; 53 | foreach ($object->commands() as $command) { 54 | $out[] = $this->commandSynopsis($command, $level+1); 55 | } 56 | } 57 | 58 | return implode("\n", $out); 59 | } 60 | 61 | private function commandSynopsis(AbstractCommandDefinition $command, int $level): string 62 | { 63 | $out = []; 64 | $title = []; 65 | $title[] = $command->name; 66 | if ($command instanceof ApplicationDefinition) { 67 | if ($command->version !== null) { 68 | $title[] = $command->version; 69 | } 70 | if ($command->author !== null) { 71 | $title[] = 'by'; 72 | $title[] = $command->author; 73 | } 74 | } 75 | $out[] = implode(' ', $title); 76 | foreach ($command->arguments() as $argument) { 77 | $out[] = sprintf('<%s>', $argument->name); 78 | } 79 | $options = []; 80 | foreach ($command->options() as $option) { 81 | $options[] = sprintf('[--%s%s]', $option->parseName, $option->short ? sprintf('|-%s', $option->short) : ''); 82 | } 83 | 84 | $out[] = implode(' ', $options); 85 | $out[] = $command->help; 86 | 87 | return $this->indent(implode("\t", $out), $level); 88 | } 89 | 90 | private function indent(string $string, int $level): string 91 | { 92 | return str_repeat(' ', $level) . $string; 93 | } 94 | 95 | private function optionSynopsis(OptionDefinition $option, int $level): string 96 | { 97 | $out = []; 98 | if ($option->short) { 99 | $out[] = sprintf("%2s, --%s\t", '-'.$option->short, $option->parseName); 100 | } else { 101 | $out[] = sprintf(" --%s\t", $option->parseName); 102 | } 103 | if ($option->help) { 104 | $out[] = $option->help; 105 | } 106 | $out[] = sprintf('(%s)', $option->type->toString()); 107 | 108 | return $this->indent(implode(' ', $out), $level); 109 | } 110 | 111 | private function printException(ExceptionContext $object): string 112 | { 113 | $out = [ 114 | sprintf('ERROR: %s', $object->throwable->getMessage()), 115 | '', 116 | ]; 117 | if ($object->throwable instanceof ParseErrorWithContext) { 118 | $out[] = $this->print($object->throwable->definition); 119 | } else { 120 | $out[] = $object->throwable->getTraceAsString(); 121 | } 122 | 123 | return implode("\n", $out); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Type/BooleanType.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class BooleanType implements Type 11 | { 12 | public function toString(): string 13 | { 14 | return 'boolean'; 15 | } 16 | 17 | public function parse(string $value): mixed 18 | { 19 | $trueValues = ['true', 'yes', 'on']; 20 | $falseValues = ['false', 'no', 'off']; 21 | if (in_array($value, $trueValues)) { 22 | return true; 23 | } 24 | if (in_array($value, $falseValues)) { 25 | return false; 26 | } 27 | 28 | throw new ParseError(sprintf( 29 | 'Boolean value must be one of: %s, got "%s"', 30 | implode(', ', array_merge($trueValues, $falseValues)), 31 | $value 32 | )); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Type/FloatType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | final class FloatType implements Type 9 | { 10 | public function toString(): string 11 | { 12 | return 'float'; 13 | } 14 | 15 | public function parse(string $value): mixed 16 | { 17 | return floatval($value); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Type/IntegerType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | final class IntegerType implements Type 9 | { 10 | public function toString(): string 11 | { 12 | return 'integer'; 13 | } 14 | 15 | public function parse(string $value): mixed 16 | { 17 | return intval($value); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Type/ListType.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | final class ListType implements Type 12 | { 13 | /** 14 | * @param IType $type 15 | */ 16 | public function __construct(private Type $type) 17 | { 18 | } 19 | 20 | public function toString(): string 21 | { 22 | return sprintf('list<%s>', $this->type->toString()); 23 | } 24 | 25 | /** 26 | * @return IType 27 | */ 28 | public function itemType(): Type 29 | { 30 | return $this->type; 31 | } 32 | 33 | public function parse(string $value): mixed 34 | { 35 | throw new BadMethodCallException('List type does not support parsing'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Type/MixedType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | final class MixedType implements Type 9 | { 10 | public function toString(): string 11 | { 12 | return 'mixed'; 13 | } 14 | 15 | public function parse(string $value): mixed 16 | { 17 | return $value; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Type/StringType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | final class StringType implements Type 9 | { 10 | public function toString(): string 11 | { 12 | return 'string'; 13 | } 14 | 15 | public function parse(string $value): mixed 16 | { 17 | return $value; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Type/Type.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public static function fromReflectionType(ReflectionType|null $type): Type 16 | { 17 | if (null === $type) { 18 | return new MixedType(); 19 | } 20 | 21 | if ($type instanceof ReflectionUnionType) { 22 | return new UnionType( 23 | ...array_map( 24 | fn (ReflectionType|null $t) => self::fromReflectionType($t), 25 | $type->getTypes(), 26 | ) 27 | ); 28 | } 29 | 30 | if (!$type instanceof ReflectionNamedType) { 31 | throw new RuntimeException(sprintf( 32 | 'Unknown reflection type "%s"', 33 | $type::class 34 | )); 35 | } 36 | 37 | return self::fromString($type->getName()); 38 | } 39 | 40 | /** 41 | * @return Type 42 | */ 43 | public static function fromString(?string $name): Type 44 | { 45 | return match ($name) { 46 | null => new MixedType(), 47 | 'string' => new StringType(), 48 | 'int' => new IntegerType(), 49 | 'bool' => new BooleanType(), 50 | 'float' => new FloatType(), 51 | 'array' => new ListType(new StringType()), 52 | default => throw new RuntimeException(sprintf( 53 | 'Do not know how to parse type "%s"', 54 | $name 55 | )), 56 | }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Type/UnionType.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | final class UnionType implements Type 9 | { 10 | /** 11 | * @var Type[] 12 | */ 13 | public array $types; 14 | 15 | /** 16 | * @param Type ...$types 17 | */ 18 | public function __construct(Type ...$types) 19 | { 20 | $this->types = $types; 21 | } 22 | 23 | public function toString(): string 24 | { 25 | return implode('|', array_map(fn (Type $type) => $type->toString(), $this->types)); 26 | } 27 | 28 | public function parse(string $value): mixed 29 | { 30 | return $value; 31 | } 32 | } 33 | --------------------------------------------------------------------------------