ArgManager.php

PHP

Path: src/CLI/ArgManager.php

<?php

namespace mini\CLI;

/**
 * Command-line argument parser with subcommand support
 *
 * Provides an immutable, fluent interface for parsing CLI arguments with support
 * for subcommands, option validation, and positional arguments.
 *
 * # Features
 *
 * - **Subcommand support**: Parse nested command structures (git commit -m "message")
 * - **Short options**: `-v`, `-vvv`, `-i value`, `-ivalue`
 * - **Long options**: `--verbose`, `--match=pattern`, `--match pattern`
 * - **Unified short/long**: Query by either name, get same result
 * - **Option validation**: Required/optional values with automatic error handling
 * - **Immutable design**: All operations return new instances
 * - **Command delegation**: Easy pass-through to external tools
 *
 * # Quick Start
 *
 * ```php
 * // Simple command: myapp -v --output=file.txt
 * $args = mini\args(
 *     (new ArgManager())
 *         ->withFlag('v', 'verbose')
 *         ->withRequiredValue('o', 'output')
 * );
 *
 * if ($args->getUnparsedArgs()) {
 *     die("Unexpected: " . implode(', ', $args->getUnparsedArgs()));
 * }
 *
 * $verbosity = $args->getFlag('verbose');
 * $output = $args->getOption('output');
 * ```
 *
 * # Subcommands
 *
 * ```php
 * // myapp -v run --fast target
 * $args = mini\args(
 *     (new ArgManager())
 *         ->withFlag('v', 'verbose')
 *         ->withSubcommand('run', 'build', 'test')
 * );
 *
 * if ($args->getUnparsedArgs()) {
 *     die("Unexpected: " . implode(', ', $args->getUnparsedArgs()));
 * }
 *
 * $sub = $args->nextCommand();
 * if ($sub?->getCommand() === 'run') {
 *     $run = $sub->withFlag(null, 'fast');
 *     $target = $run->getUnparsedArgs()[0] ?? null;
 * }
 * ```
 *
 * # Stopping Option Parsing
 *
 * Use `--` to stop option parsing and treat everything after as unparsed arguments:
 *
 * ```php
 * // myapp -v -- --not-an-option file.txt
 * $args = mini\args((new ArgManager())->withFlag('v', 'verbose'));
 * $args->getUnparsedArgs(); // ['--not-an-option', 'file.txt']
 * ```
 *
 * @see mini\args() Helper function to configure and access ArgManager
 */
class ArgManager
{
    /** @var int Next unparsed argument index */
    private int $next_index;

    /** @var array<string, array{short: string, long: string, type: 'flag'|'required'|'optional'}> Declared options */
    private array $declared = [];

    /** @var array<string, string> Map short names to canonical (long) names */
    private array $shortToCanonical = [];

    /** @var array<string> Declared subcommands */
    private array $subcommands = [];

    /** @var array|null Cached parsed options (canonical names) */
    private ?array $parsedOpts = null;

    /** @var array|null Cached unparsed arguments */
    private ?array $unparsedArgs = null;

    /** @var string|null Matched subcommand (if any) */
    private ?string $matchedSubcommand = null;

    /** @var array|null Custom argv array (null = use $_SERVER['argv']) */
    private ?array $customArgv = null;

    /** @var ArgManager|null Parent command (if this is a subcommand) */
    private ?ArgManager $parent = null;

    /**
     * Create a new ArgManager instance
     *
     * @param int $start_index Index in argv where this command starts (default: 0)
     */
    public function __construct(
        private readonly int $start_index = 0
    ) {
        $this->next_index = $this->start_index + 1;
    }

    /**
     * Create an ArgManager from a custom argv array
     *
     * Use this for parsing command strings in REPLs or testing.
     *
     * ```php
     * // Parse a REPL command line
     * $args = ArgManager::parse(['schema', '--verbose', 'users'])
     *     ->withFlag('v', 'verbose')
     *     ->withSubcommand('users', 'orders');
     *
     * $args->getCommand();      // 'schema'
     * $args->getFlag('verbose'); // 1
     * $args->nextCommand();      // ArgManager for 'users'
     * ```
     *
     * @param array $argv Array of arguments (like $_SERVER['argv'])
     * @return static New ArgManager instance parsing the given array
     */
    public static function parse(array $argv): static
    {
        $instance = new static(0);
        $instance->customArgv = array_values($argv); // Ensure 0-indexed
        return $instance;
    }

    /**
     * Get the argv array to parse
     */
    private function getArgv(): array
    {
        return $this->customArgv ?? $_SERVER['argv'] ?? [];
    }

    /**
     * Declare a boolean flag option
     *
     * Flags don't take values. They can be repeated for counting (e.g., -vvv for verbosity level).
     *
     * @param string|null $short Single-character short option (e.g., 'v' for -v)
     * @param string|null $long Long option name (e.g., 'verbose' for --verbose)
     * @return static New instance with flag declared
     * @throws \InvalidArgumentException If option already declared or both are null
     *
     * @example
     * ```php
     * $args = $args->withFlag('v', 'verbose');
     * // Command: myapp -vvv
     * $verbosity = $args->getFlag('verbose'); // 3
     * ```
     */
    public function withFlag(?string $short = null, ?string $long = null): static
    {
        return $this->declareOption($short, $long, 'flag');
    }

    /**
     * Declare an option that requires a value
     *
     * @param string|null $short Single-character short option
     * @param string|null $long Long option name
     * @return static New instance with option declared
     *
     * @example
     * ```php
     * $args = $args->withRequiredValue('i', 'input');
     * // Command: myapp -i file.txt  OR  myapp --input=file.txt
     * $input = $args->getOption('input'); // 'file.txt'
     * ```
     */
    public function withRequiredValue(?string $short = null, ?string $long = null): static
    {
        return $this->declareOption($short, $long, 'required');
    }

    /**
     * Declare an option with an optional value
     *
     * @param string|null $short Single-character short option
     * @param string|null $long Long option name
     * @param string|null $default Default value when option is present without a value
     * @return static New instance with option declared
     *
     * @example
     * ```php
     * $args = $args->withOptionalValue('l', 'log', '/var/log/app.log');
     * // Command: myapp              → getOption('log') = null
     * // Command: myapp --log        → getOption('log') = '/var/log/app.log'
     * // Command: myapp --log=other  → getOption('log') = 'other'
     * ```
     */
    public function withOptionalValue(?string $short = null, ?string $long = null, ?string $default = null): static
    {
        return $this->declareOption($short, $long, 'optional', $default);
    }

    /**
     * Declare valid subcommands
     *
     * When a declared subcommand is encountered, it's consumed and available via nextCommand().
     * Undeclared subcommands or unknown options end up in getUnparsedArgs().
     *
     * @param string ...$subcommands Valid subcommand names
     * @return static New instance with subcommands declared
     *
     * @example
     * ```php
     * $args = $args->withSubcommand('run', 'build', 'test');
     *
     * if ($args->getUnparsedArgs()) {
     *     die("Unexpected: " . implode(', ', $args->getUnparsedArgs()));
     * }
     *
     * $sub = $args->nextCommand();
     * match ($sub?->getCommand()) {
     *     'run' => handleRun($sub),
     *     'build' => handleBuild($sub),
     *     'test' => handleTest($sub),
     *     null => showHelp(),
     * };
     * ```
     */
    public function withSubcommand(string ...$subcommands): static
    {
        $clone = clone $this;
        $clone->subcommands = array_merge($clone->subcommands, $subcommands);
        $clone->parsedOpts = null;
        $clone->unparsedArgs = null;
        $clone->matchedSubcommand = null;
        return $clone;
    }

    /**
     * Get the count of times a flag was provided
     *
     * @param string $name Short or long option name
     * @return int Count (0 if not present)
     * @throws \RuntimeException If option not declared
     */
    public function getFlag(string $name): int
    {
        $this->ensureParsed();
        $canonical = $this->resolveToCanonical($name);

        $value = $this->parsedOpts[$canonical] ?? null;
        if ($value === null) {
            return 0;
        }
        if (is_array($value)) {
            return count($value);
        }
        return 1;
    }

    /**
     * Get the value of an option
     *
     * @param string $name Short or long option name
     * @return string|false|array|null Value, false (present without value and no default), array (repeated), or null (absent)
     * @throws \RuntimeException If option not declared
     */
    public function getOption(string $name): string|array|false|null
    {
        $this->ensureParsed();
        $canonical = $this->resolveToCanonical($name);

        $value = $this->parsedOpts[$canonical] ?? null;

        // Apply default for optional value options present without a value
        if ($value === false) {
            $default = $this->declared[$canonical]['default'] ?? null;
            if ($default !== null) {
                return $default;
            }
        }

        return $value;
    }

    /**
     * Check if an option was provided
     *
     * @param string $name Short or long option name
     * @return bool True if option was present on command line
     * @throws \RuntimeException If option not declared
     */
    public function hasOption(string $name): bool
    {
        return $this->getOption($name) !== null;
    }

    /**
     * Get unparsed arguments (unknown options, undeclared subcommands, or args after --)
     *
     * For simple commands without subcommands, check this to detect invalid input:
     * ```php
     * if ($args->getUnparsedArgs()) {
     *     die("Unexpected: " . implode(', ', $args->getUnparsedArgs()));
     * }
     * ```
     *
     * @return array<string> Unparsed arguments
     */
    public function getUnparsedArgs(): array
    {
        $this->ensureParsed();
        return $this->unparsedArgs;
    }

    /**
     * Get the command name at this context's starting position
     *
     * @return string|null Command name or null if argv is empty
     */
    public function getCommand(): ?string
    {
        return $this->getArgv()[$this->start_index] ?? null;
    }

    /**
     * Get a new ArgManager for the next subcommand
     *
     * Only returns a subcommand if it was declared via withSubcommand().
     * Unknown positional arguments go to getUnparsedArgs() instead.
     *
     * @return ArgManager|null New ArgManager at subcommand position, or null if none
     */
    public function nextCommand(): ?ArgManager
    {
        $this->ensureParsed();

        if ($this->matchedSubcommand === null) {
            return null;
        }

        $child = new self($this->next_index);
        $child->customArgv = $this->customArgv; // Inherit custom argv
        $child->parent = $this; // Link back to parent
        return $child;
    }

    /**
     * Get the parent command's ArgManager
     *
     * When processing subcommands, use this to access flags declared on the parent.
     *
     * ```php
     * mini\args($sub);  // Step into subcommand
     * $verbose = mini\args()->parentCommand()?->getFlag('verbose');
     * ```
     *
     * @return ArgManager|null Parent command, or null if this is the root command
     */
    public function parentCommand(): ?ArgManager
    {
        return $this->parent;
    }

    /**
     * Get all remaining arguments from current position (unparsed)
     *
     * Useful for delegating to external commands. If the remaining args
     * start with '--', it is stripped since it was meant to stop our
     * parser, not the external command's.
     *
     * @return array<string> Remaining argv elements
     */
    public function getRemainingArgs(): array
    {
        $remaining = array_slice($this->getArgv(), $this->next_index);

        // Strip leading '--' - it was meant for our parser, not the delegate
        if ($remaining !== [] && $remaining[0] === '--') {
            array_shift($remaining);
        }

        return $remaining;
    }

    /**
     * Declare an option
     */
    private function declareOption(?string $short, ?string $long, string $type, ?string $default = null): static
    {
        $short = $short ?? '';
        $long = $long ?? '';

        if ($short === '' && $long === '') {
            throw new \InvalidArgumentException('At least one of short or long option name must be provided');
        }

        // Check for duplicates
        if ($short !== '' && isset($this->shortToCanonical[$short])) {
            throw new \InvalidArgumentException("Short option '-{$short}' already declared");
        }
        if ($long !== '' && isset($this->declared[$long])) {
            throw new \InvalidArgumentException("Long option '--{$long}' already declared");
        }
        if ($short !== '' && isset($this->declared[$short])) {
            throw new \InvalidArgumentException("Option '{$short}' already declared as long option");
        }

        $clone = clone $this;

        // Canonical name is long if available, else short
        $canonical = $long !== '' ? $long : $short;
        $clone->declared[$canonical] = ['short' => $short, 'long' => $long, 'type' => $type, 'default' => $default];

        if ($short !== '') {
            $clone->shortToCanonical[$short] = $canonical;
        }

        $clone->parsedOpts = null;
        $clone->unparsedArgs = null;
        $clone->matchedSubcommand = null;
        return $clone;
    }

    /**
     * Resolve option name to canonical form
     */
    private function resolveToCanonical(string $name): string
    {
        // Direct match on canonical name
        if (isset($this->declared[$name])) {
            return $name;
        }

        // Short name lookup
        if (isset($this->shortToCanonical[$name])) {
            return $this->shortToCanonical[$name];
        }

        throw new \RuntimeException(
            "Option '{$name}' not declared. Use withFlag(), withRequiredValue(), or withOptionalValue() first."
        );
    }

    /**
     * Parse argv if not already done
     */
    private function ensureParsed(): void
    {
        if ($this->parsedOpts !== null) {
            return;
        }

        $argv = $this->getArgv();
        $argc = count($argv);
        $opts = [];
        $unparsed = [];
        $i = $this->start_index + 1;
        $subcommandIndex = null;

        while ($i < $argc) {
            $arg = $argv[$i];

            // End of options marker
            if ($arg === '--') {
                $i++;
                break;
            }

            // Long option
            if (str_starts_with($arg, '--')) {
                $result = $this->parseLongOption($argv, $i, $opts);
                if ($result === false) {
                    // Unknown option - goes to unparsed
                    $unparsed[] = $arg;
                    $i++;
                    continue;
                }
                $i = $result;
                continue;
            }

            // Short option(s)
            if (str_starts_with($arg, '-') && strlen($arg) > 1) {
                $result = $this->parseShortOptions($argv, $i, $opts);
                if ($result === false) {
                    // Unknown option - goes to unparsed
                    $unparsed[] = $arg;
                    $i++;
                    continue;
                }
                $i = $result;
                continue;
            }

            // Positional argument - check if it's a declared subcommand
            if (in_array($arg, $this->subcommands, true)) {
                $this->matchedSubcommand = $arg;
                $subcommandIndex = $i;
                $i++;
                break; // Stop parsing, rest belongs to subcommand
            }

            // Not a declared subcommand - goes to unparsed
            $unparsed[] = $arg;
            $i++;
        }

        // Collect remaining args after --
        while ($i < $argc) {
            // If we matched a subcommand, don't collect - they belong to it
            if ($this->matchedSubcommand !== null) {
                break;
            }
            $unparsed[] = $argv[$i++];
        }

        // Normalize to canonical names
        $normalized = [];
        foreach ($opts as $key => $value) {
            $canonical = $this->shortToCanonical[$key] ?? $key;
            if (isset($normalized[$canonical])) {
                // Merge repeated options
                if (!is_array($normalized[$canonical])) {
                    $normalized[$canonical] = [$normalized[$canonical]];
                }
                if (is_array($value)) {
                    $normalized[$canonical] = array_merge($normalized[$canonical], $value);
                } else {
                    $normalized[$canonical][] = $value;
                }
            } else {
                $normalized[$canonical] = $value;
            }
        }

        $this->parsedOpts = $normalized;
        $this->unparsedArgs = $unparsed;
        $this->next_index = $subcommandIndex ?? $argc;
    }

    /**
     * Parse a long option, return next index or false if unknown
     */
    private function parseLongOption(array $argv, int $i, array &$opts): int|false
    {
        $arg = $argv[$i];
        $eq = strpos($arg, '=');
        $name = $eq === false ? substr($arg, 2) : substr($arg, 2, $eq - 2);
        $inlineValue = $eq === false ? null : substr($arg, $eq + 1);

        // Find declaration
        if (!isset($this->declared[$name])) {
            return false;
        }

        $decl = $this->declared[$name];
        $type = $decl['type'];

        if ($type === 'flag') {
            $this->addOpt($opts, $name, false);
            return $i + 1;
        }

        if ($type === 'required') {
            if ($inlineValue !== null) {
                $this->addOpt($opts, $name, $inlineValue);
            } elseif (isset($argv[$i + 1]) && !str_starts_with($argv[$i + 1], '-')) {
                $this->addOpt($opts, $name, $argv[++$i]);
            } else {
                throw new \RuntimeException("Option --{$name} requires a value");
            }
            return $i + 1;
        }

        // Optional value
        if ($inlineValue !== null) {
            $this->addOpt($opts, $name, $inlineValue);
        } elseif (isset($argv[$i + 1]) && !str_starts_with($argv[$i + 1], '-')) {
            $this->addOpt($opts, $name, $argv[++$i]);
        } else {
            $this->addOpt($opts, $name, false);
        }
        return $i + 1;
    }

    /**
     * Parse short option(s), return next index or false if unknown
     */
    private function parseShortOptions(array $argv, int $i, array &$opts): int|false
    {
        $arg = $argv[$i];
        $chars = substr($arg, 1);
        $len = strlen($chars);

        for ($j = 0; $j < $len; $j++) {
            $ch = $chars[$j];

            // Find declaration
            if (!isset($this->shortToCanonical[$ch])) {
                return false;
            }

            $canonical = $this->shortToCanonical[$ch];
            $decl = $this->declared[$canonical];
            $type = $decl['type'];

            if ($type === 'flag') {
                $this->addOpt($opts, $ch, false);
                continue;
            }

            // Option with value - rest of chars or next arg is value
            $rest = substr($chars, $j + 1);

            if ($rest !== '') {
                $this->addOpt($opts, $ch, $rest);
                return $i + 1;
            }

            if ($type === 'required') {
                if (isset($argv[$i + 1]) && !str_starts_with($argv[$i + 1], '-')) {
                    $this->addOpt($opts, $ch, $argv[++$i]);
                } else {
                    throw new \RuntimeException("Option -{$ch} requires a value");
                }
            } else {
                // Optional
                if (isset($argv[$i + 1]) && !str_starts_with($argv[$i + 1], '-')) {
                    $this->addOpt($opts, $ch, $argv[++$i]);
                } else {
                    $this->addOpt($opts, $ch, false);
                }
            }
            return $i + 1;
        }

        return $i + 1;
    }

    /**
     * Add option value, handling repeated options as arrays
     */
    private function addOpt(array &$opts, string $key, mixed $val): void
    {
        if (array_key_exists($key, $opts)) {
            if (!is_array($opts[$key])) {
                $opts[$key] = [$opts[$key]];
            }
            $opts[$key][] = $val;
        } else {
            $opts[$key] = $val;
        }
    }
}