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);
}
@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