Skip to content

Instantly share code, notes, and snippets.

@tored
Last active September 30, 2021 17:33
Show Gist options
  • Save tored/43f6f9f2d8b85fc9fbbd82fa9063c76d to your computer and use it in GitHub Desktop.
Save tored/43f6f9f2d8b85fc9fbbd82fa9063c76d to your computer and use it in GitHub Desktop.
getOptions: experimental command line parser for PHP by using attributes
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Option
{
public function __construct(
public string|null $short = null,
public string|null $long = null,
public bool $required = false,
public bool $valued = false)
{
}
public function __toString(): string
{
return spl_object_hash($this);
}
}
final class Argument
{
public function __construct(
public string $arg,
public string|null $value = null,
public Option|null $option = null,
)
{
}
}
final class Arguments
{
/**
* @param Argument[] $args
* @param Argument[] $missing
* @param Argument[] $extras
*/
public function __construct(private array $args, private array $missing, private array $extras)
{
}
/**
* All arguments in correct order
*
* @return Argument[]
*/
public function getArguments(): array
{
return $this->args;
}
public function hasPositionalArguments(): bool
{
return !empty($this->getPositionalArguments());
}
/**
* All positional arguments in correct order
*
* @return Argument[]
*/
public function getPositionalArguments(): array
{
$args = [];
foreach ($this->args as $arg) {
if ($arg->option === null) {
$args[] = $arg;
}
}
return $args;
}
public function hasMissingOptions(): bool
{
return empty($this->missing);
}
/**
* Required Options not found
*
* @return Option[]
*/
public function getMissingOptions(): array
{
return $this->missing;
}
public function hasExtraArguments(): bool
{
return empty($this->extras);
}
/**
* Extra arguments that was never specified as an Option
*
* @return Argument[]
*/
public function getExtraArguments(): array
{
return $this->extras;
}
}
/**
* @param string[] $argv
* @param callable $callable
* @throws ReflectionException
*/
function getOptions(array $argv, callable $callable): mixed
{
$rf = new ReflectionFunction(Closure::fromCallable($callable));
/** @var array<string, Option> */
$shorts = [];
/** @var array<string, Option> */
$longs = [];
/** @var Option[] */
$required = [];
foreach ($rf->getAttributes(Option::class) as $attribute) {
/** @var Option $option */
$option = $attribute->newInstance();
if (isset($option->short)) {
if (isset($shorts[$option->short])) {
throw new LogicException("Short option already used {$option->short}");
}
$shorts[$option->short] = $option;
}
if (isset($option->long)) {
if (isset($longs[$option->long])) {
throw new LogicException("Long option already used {$option->long}");
}
$longs[$option->long] = $option;
}
if ($option->required) {
$required[] = $option;
}
}
/** @var Arguments[] */
$args = [];
/** @var Option[] */
$found = [];
/** @var Argument[] */
$extras = [];
/** @var Argument|null */
$valued = null;
foreach ($argv as $arg) {
if ($arg[0] !== '-') {
// if a previous Option does not require a value, it will be treated as a positional argument
if ($valued === null) {
$args[] = new Argument($arg);
} else {
$valued->value = $arg;
$valued = null;
}
} else {
$left = ltrim($arg, '-');
$right = null;
if (str_contains($left, '=')) {
[$left, $right] = explode('=', $left, 2);
}
if (isset($arg[1]) && $arg[1] === '-') {
$options = $longs;
} else {
$options = $shorts;
}
$option = null;
if (isset($options[$left])) {
/** @var Option $option */
$option = $options[$left];
$found[] = $option;
// $right can have a value here from split on equal (=) even though Option->valued can be false
$argument = new Argument($left, $right, $option);
$args[] = $argument;
if ($option->valued && $right === null) {
$valued = $argument;
}
} else {
$argument = new Argument($left, $right, null);
$args[] = $argument;
$extras[] = $argument;
}
}
}
// TODO missing checks if Options that require values has them
$missing = array_diff($required, $found);
$arguments = new Arguments($args, $missing, $extras);
return $callable($arguments);
}
@JosephLeedy
Copy link

Nice, but I'd recommend following PSR-12 coding standards as I had trouble finding the closing parenthesis (")") in some of your methods.

@tored
Copy link
Author

tored commented Sep 29, 2021

Nice, but I'd recommend following PSR-12 coding standards as I had trouble finding the closing parenthesis (")") in some of your methods.

Not sure if I follow PSR-12 coding standard, for this gist I used PhpStorm autoformat, have to look into if Phpstorm follows it or not.

However the getOptions() functions is long and ugly, so probably very hard to read. It should be split into multiple functions and then put into a proper class.

Edit Thanks btw!

@p810
Copy link

p810 commented Sep 29, 2021

This is cool, nice work!

By the way, you're probably already aware but there's a shorter syntax for nullable types:

public function __construct(
    public ?string $short = null,
    public ?string $long = null,
    public bool $required = false,
    public bool $valued = false,
) {}

Also I wasn't quite sure what $valued represented at first glance. It'd be a bit longer, but maybe a name like $expectsValue could be beneficial since it's more descriptive?

@tored
Copy link
Author

tored commented Sep 29, 2021

Thanks!

Yep, I'm aware of the shorter nullable syntax, I just can't make up my mind when using PHP 8 union types if I should always use the long form for everything for consistency or if I should use the short form for nullables when possible. Do we have any PSR guideline about that?

Also I wasn't quite sure what $valued represented at first glance. It'd be a bit longer, but maybe a name like $expectsValue could be beneficial since it's more descriptive?

$valued was a really bad name pick. I wanted something short so it is easy to write for the attribute, but my choice was not the best one. It was a late addition. $expectsValue is descriptive enough to be understandable, I wish there was something shorter.

@p810
Copy link

p810 commented Sep 30, 2021

Admittedly I haven't been writing as much PHP lately so I'm not sure if PSR's up to date with union types and all that. I think last I saw, they were planning on introducing something like an "evolving spec" that would be kept more up to date with PHP's new features? But anyway, since they're functionally equivalent it's fine either way - just wanted to mention it in case you hadn't seen it before.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment