andreypostal/cli

View on GitHub
src/App.php

Summary

Maintainability
A
0 mins
Test Coverage
<?php
namespace Andrey\Cli;

use InvalidArgumentException;
use Andrey\Cli\Exceptions\CliException;

use Andrey\Cli\Types\Command;
use Andrey\Cli\Types\ConsoleLevel;
use Andrey\Cli\Types\Context;
use Andrey\Cli\Types\Param;

use Andrey\Cli\Utils\Output;

readonly class App
{
    use Output;

    private const HELP_CMD = 'help';

    /**
     * @throws CliException
     */
    public function __construct(
        private string $appName,
        private string $description,
        private string $cmd,
        /** @var Param[] */
        private array $params,
        /** @var Command[] */
        private array $commands,
    ) {
        if (PHP_SAPI !== 'cli') {
            throw new CliException('This tool can only be executed from CLI');
        }
    }

    public function run(array $argv): void
    {
        $key = mb_strtolower($argv[1] ?? 'default');

        $findArr = array_filter($this->commands, static fn(Command $cmd): bool => mb_strtolower($cmd->key) === $key);
        $command = array_values($findArr)[0] ?? null;

        if ($command === null && $key === self::HELP_CMD) {
            $this->helper();
            return;
        }

        $command ?? throw new InvalidArgumentException('Command not found.');
        $this->writeAppHeader($command);

        // Handle argv
        $argv = array_splice($argv, 1);
        $context = new Context(
            params: $this->parseParams(
                $command,
                [
                    'cmd',
                    ...$argv,
                ],
            ),
        );

        if (isset($command->service['handler']) && is_callable($command->service['handler'])) {
            $command->service['handler']($context);
            return;
        }

        if (is_string($command->service['instance'])) {
            $instance = new ($command->service['instance'])();
        } elseif (is_callable($command->service['instance'])) {
            $instance = $command->service['instance']();
        } else {
            throw new InvalidArgumentException('Invalid service provided to command '.$key);
        }

        if (!method_exists($instance, $command->service['entrypoint'])) {
            throw new InvalidArgumentException('Invalid entrypoint provided to command '.$key);
        }

        $instance->{$command->service['entrypoint']}($context);
    }

    private function writeAppHeader(Command $command): void
    {
        echo self::CLEAR;

        self::newLine();
        self::center("Starting Service: {$this->appName} - {$command->key}", ConsoleLevel::HIGHLIGHT);
        self::newLine();
    }

    /**
     * @return array<string, Param>
     */
    private function parseParams(Command $command, array $argv): array
    {
        $preparedParams = [
            'cmd' => new Param(
                key: 'cmd',
                flag: 'cmd',
                required: true,
            ),
        ];

        foreach ($this->params as $param) {
            $preparedParams[$param->key] = $param;
            $preparedParams[$param->flag] = $param;
        }

        foreach ($command->params as $param) {
            $preparedParams[$param->key] = $param;
            $preparedParams[$param->flag] = $param;
        }

        // Parse the parameters value based on the $argv content
        $output = [];
        $currentParam = null;
        foreach ($argv as $arg) {
            $arg = trim(ltrim($arg, '-'));
            if ($arg === '') {
                continue;
            }

            if ($currentParam === null) {
                $currentParam = $preparedParams[$arg] ?? null;
                if ($currentParam?->isBool) {
                    $output[$currentParam->key] = $currentParam->withValue(true);
                    $output[$currentParam->flag] = $currentParam->withValue(true);

                    unset($preparedParams[$currentParam->key], $preparedParams[$currentParam->flag]);
                    $currentParam = null;
                }
                continue;
            }

            $output[$currentParam->key] = $currentParam->withValue($arg);
            $output[$currentParam->flag] = $currentParam->withValue($arg);
            unset($preparedParams[$currentParam->key], $preparedParams[$currentParam->flag]);
            $currentParam = null;
        }

        // The remaining parameters (not present in the $argv) still in the $preparedParams
        // We need to validate required parameters and assign the ones with default values as well
        foreach ($preparedParams as $param) {
            if ($param->required) {
                throw new InvalidArgumentException("Validation Error: Field {$param->key} is required.");
            }

            if ($param->defaultValue !== null) {
                $output[$param->key] = $param->withValue($param->defaultValue);
                $output[$param->flag] = $param->withValue($param->defaultValue);
            }
        }

        return $output;
    }

    private function helper(): void
    {
        echo self::CLEAR;
        self::newLine();
        self::center($this->appName, ConsoleLevel::HIGHLIGHT);
        self::center($this->description);

        $close = self::STYLE_CLOSE;
        $bold = self::STYLE_BOLD;

        $globalParams = '';
        foreach ($this->params as $param) {
            $globalParams .= $this->parseParamHelper($param);
        }

        $commands = '';
        foreach ($this->commands as $command) {
            $commands .= $this->parseCommandHelper($command);
        }

        echo <<<HELPER

  {$bold}Global Params:{$close}
{$globalParams}
{$commands}
HELPER;
    }

    private function parseCommandHelper(Command $command): string
    {
        $eol = PHP_EOL;
        $close = self::STYLE_CLOSE;
        $bold = self::STYLE_BOLD;
        $gray = self::STYLE_GRAY;

        $hasParams = count($command->params) > 0;

        $paramsUsage = '';
        $params = '';
        foreach ($command->params as $param) {
            $params .= $this->parseParamHelper($param);

            $value = $param->isBool ? '' : '<value> ';
            $paramsUsage .= "-{$param->flag} {$value}";
        }

        $finalParams = <<<PARAMS
    {$bold}Params:{$close}
{$params} {$eol}
PARAMS;
        if (!$hasParams) {
            $finalParams = '';
        }

        return <<<COMMAND
  {$bold}Command <{$command->key}> Usage:{$close}
    {$gray}{$this->cmd} {$command->key} {$paramsUsage}[...globals]{$close}
{$finalParams}
COMMAND;
    }

    private function parseParamHelper(Param $param): string
    {
        $eol = PHP_EOL;

        $close = self::STYLE_CLOSE;
        $bold = self::STYLE_BOLD;
        $red = self::STYLE_RED;

        $required = $param->required ? "{$bold}{$red}* {$close}" : '  ';

        return <<<PARAM
   {$required}--{$param->key} [-{$param->flag}] | {$param->helpText} {$eol}
PARAM;
    }
}