Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
224 / 224
98.64% covered (success)
98.64%
218 / 221
9.93% covered (danger)
9.93%
61 / 614
82.35% covered (warning)
82.35%
14 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Runner
100.00% covered (success)
100.00%
224 / 224
98.64% covered (success)
98.64%
218 / 221
9.93% covered (danger)
9.93%
61 / 614
100.00% covered (success)
100.00%
17 / 17
7553.69
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 orderCommands
100.00% covered (success)
100.00%
23 / 23
95.65% covered (success)
95.65%
22 / 23
4.30% covered (danger)
4.30%
4 / 93
100.00% covered (success)
100.00%
1 / 1
79.99
 showHelp
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
12 / 12
11.11% covered (danger)
11.11%
2 / 18
100.00% covered (success)
100.00%
1 / 1
22.56
 showCommands
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
16 / 16
0.00% covered (danger)
0.00%
0 / 36
100.00% covered (success)
100.00%
1 / 1
7
 run
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
20 / 20
75.00% covered (warning)
75.00%
9 / 12
100.00% covered (success)
100.00%
1 / 1
11.56
 normalizeOptions
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
22 / 22
7.81% covered (danger)
7.81%
5 / 64
100.00% covered (success)
100.00%
1 / 1
72.46
 runCommand
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
9 / 9
55.56% covered (warning)
55.56%
5 / 9
100.00% covered (success)
100.00%
1 / 1
7.19
 bind
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
20 / 20
10.61% covered (danger)
10.61%
7 / 66
100.00% covered (success)
100.00%
1 / 1
66.86
 validate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateOptions
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
32 / 32
2.10% covered (danger)
2.10%
5 / 238
100.00% covered (success)
100.00%
1 / 1
226.12
 validateArguments
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
22 / 22
22.22% covered (danger)
22.22%
8 / 36
100.00% covered (success)
100.00%
1 / 1
67.93
 unknownOption
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
13 / 13
11.11% covered (danger)
11.11%
2 / 18
100.00% covered (success)
100.00%
1 / 1
31.28
 showCommandHelp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 echoGroup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 echoCommand
100.00% covered (success)
100.00%
3 / 3
85.71% covered (warning)
85.71%
6 / 7
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
2.50
 showAmbiguousMessage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
4 / 4
33.33% covered (danger)
33.33%
1 / 3
100.00% covered (success)
100.00%
1 / 1
3.19
 getCommand
100.00% covered (success)
100.00%
12 / 12
92.86% covered (success)
92.86%
13 / 14
50.00% covered (danger)
50.00%
6 / 12
100.00% covered (success)
100.00%
1 / 1
10.50
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Console;
6
7use Closure;
8use ReflectionFunction;
9use ReflectionNamedType;
10use Throwable;
11use ValueError;
12
13/**
14 * @api
15 */
16final class Runner
17{
18    private const AMBIGUOUS = 1;
19
20    /**
21     * The commands ordered by group and name.
22     *
23     * @var array<string, array{title: string, commands: array<string, Entry>}>
24     */
25    private array $toc = [];
26
27    /**
28     * The commands indexed by name only.
29     *
30     * @var array<string, list<Entry>>
31     */
32    private array $list = [];
33    private Io $io;
34    private int $longestName = 0;
35
36    /**
37     * An Io instance given as `$output` is used as is; `$errorOutput`
38     * then has no effect.
39     */
40    public function __construct(
41        Commands $commands,
42        string|Io $output = 'php://stdout',
43        string $errorOutput = 'php://stderr',
44        private bool $debug = false,
45    ) {
46        $this->io = is_string($output) ? new Io($output, $errorOutput) : $output;
47        $this->orderCommands($commands);
48    }
49
50    private function orderCommands(Commands $commands): void
51    {
52        $groups = [];
53
54        foreach ($commands->entries() as $entry) {
55            $meta = $entry->meta;
56
57            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
58                throw new ValueError("Command name '{$meta->name}' is reserved");
59            }
60
61            if (!array_key_exists($meta->prefix, $groups)) {
62                $groups[$meta->prefix] = [
63                    'title' => $meta->title(),
64                    'commands' => [],
65                ];
66            }
67
68            if (array_key_exists($meta->name, $groups[$meta->prefix]['commands'])) {
69                throw new ValueError("Duplicate command '{$meta->full()}'");
70            }
71
72            $groups[$meta->prefix]['commands'][$meta->name] = $entry;
73            $this->list[$meta->name][] = $entry;
74
75            $len = strlen($meta->full());
76            $this->longestName = $len > $this->longestName ? $len : $this->longestName;
77        }
78
79        $this->longestName = max($this->longestName, strlen('commands'));
80
81        ksort($groups);
82
83        foreach ($groups as $name => $group) {
84            $commands = $group['commands'];
85            ksort($commands);
86            $group['commands'] = $commands;
87            $this->toc[$name] = $group;
88        }
89    }
90
91    public function showHelp(): int
92    {
93        $script = $_SERVER['argv'][0] ?? '';
94        $this->io->echo("<yellow>Usage:</yellow>\n");
95        $this->io->echo("  php {$script} [prefix:]command [arguments]\n\n");
96        $this->io->echo("Prefixes are optional if the command is unambiguous.\n\n");
97        $this->io->echo("Available commands:\n");
98        $this->echoGroup('General');
99        $this->echoCommand('', 'commands', 'Lists all available commands');
100        $this->echoCommand('', 'help', 'Displays this overview');
101
102        foreach ($this->toc['']['commands'] ?? [] as $name => $entry) {
103            $this->echoCommand('', $name, $entry->meta->description);
104        }
105
106        foreach ($this->toc as $prefix => $group) {
107            if ($prefix === '') {
108                continue;
109            }
110
111            $this->echoGroup($group['title']);
112
113            foreach ($group['commands'] as $name => $entry) {
114                $this->echoCommand($prefix, $name, $entry->meta->description);
115            }
116        }
117
118        return 0;
119    }
120
121    /**
122     * Displays a list of all available commands.
123     *
124     * With and without namespace/group. If a bare name is shared, e. g.
125     * by foo:cmd and bar:cmd, only the namespaced forms are displayed â€”
126     * unless the bare name belongs to an unprefixed command, which always
127     * shows since it resolves exactly.
128     */
129    public function showCommands(): int
130    {
131        $list = [];
132
133        foreach ($this->toc as $group) {
134            foreach ($group['commands'] as $entry) {
135                $meta = $entry->meta;
136
137                if ($meta->prefix !== '') {
138                    $key = $meta->full();
139                    $list[$key] = ($list[$key] ?? 0) + 1;
140                }
141
142                $list[$meta->name] = ($list[$meta->name] ?? 0) + 1;
143            }
144        }
145
146        ksort($list);
147
148        foreach ($list as $name => $count) {
149            if ($count === 1 || array_key_exists($name, $this->toc['']['commands'] ?? [])) {
150                $this->io->echo("{$name}\n");
151            }
152        }
153
154        return 0;
155    }
156
157    public function run(): int
158    {
159        try {
160            $argv = $_SERVER['argv'] ?? [];
161            $arg = $argv[1] ?? null;
162
163            if ($arg === null) {
164                return $this->showHelp();
165            }
166
167            $cmd = strtolower($arg);
168            $isHelpCall = false;
169
170            if ($cmd === 'help') {
171                $isHelpCall = true;
172                $arg = $argv[2] ?? null;
173
174                if ($arg === null) {
175                    return $this->showHelp();
176                }
177
178                $cmd = strtolower($arg);
179            }
180
181            if ($cmd === 'commands') {
182                return $this->showCommands();
183            }
184
185            $tokens = array_slice($argv, offset: 2);
186
187            try {
188                $entry = $this->getCommand($cmd);
189            } catch (ValueError $e) {
190                if ($e->getCode() === self::AMBIGUOUS) {
191                    return $this->showAmbiguousMessage($cmd);
192                }
193
194                throw $e;
195            }
196
197            if ($isHelpCall) {
198                return $this->showCommandHelp($entry);
199            }
200
201            $args = new Args($this->normalizeOptions($entry, $tokens));
202
203            return $this->runCommand($entry, $args);
204        } catch (Throwable $e) {
205            // Escape the arbitrary strings: a message containing markup
206            // (or broken markup) must never throw while reporting. `$arg`
207            // names the effective target, e.g. `x` for `help x`.
208            $this->io->echoErr("Error while running command '");
209            $this->io->echoErr($this->io->escape($arg ?? '<no command given>'));
210            $this->io->echoErr("':\n\n" . $this->io->escape($e->getMessage()) . "\n");
211
212            if ($this->debug) {
213                $this->io->echolnErr("\n<yellow>Traceback:</yellow>");
214                $this->io->echolnErr($this->io->escape($e->getTraceAsString()));
215            }
216
217            return 1;
218        }
219    }
220
221    /**
222     * @param list<string> $tokens
223     * @return list<string>
224     */
225    private function normalizeOptions(Entry $entry, array $tokens): array
226    {
227        $aliases = [];
228
229        foreach ($entry->opts() as $opt) {
230            if ($opt->short !== '') {
231                $aliases[$opt->short] = $opt->long;
232            }
233        }
234
235        $normalized = [];
236        $literal = false;
237
238        foreach ($tokens as $token) {
239            // Aliasing stops at the `--` separator; Args reads every
240            // later token as a positional. The token is command-line
241            // input, not a secret.
242            // @mago-expect lint:no-insecure-comparison
243            if ($literal || $token === '--') {
244                $literal = true;
245                $normalized[] = $token;
246
247                continue;
248            }
249
250            $separator = strpos(haystack: $token, needle: '=');
251            $name = $separator === false
252                ? $token
253                : substr(string: $token, offset: 0, length: $separator);
254            $long = $aliases[$name] ?? null;
255            $normalized[] = $long === null
256                ? $token
257                : $long . ($separator === false ? '' : substr(string: $token, offset: $separator));
258        }
259
260        return $normalized;
261    }
262
263    private function runCommand(Entry $entry, Args $args): int
264    {
265        $this->validate($entry, $args);
266        $command = $entry->command();
267        $full = $entry->meta->full();
268
269        if (!is_callable($command)) {
270            throw new ValueError("Command '{$full}' is not callable");
271        }
272
273        $function = new ReflectionFunction(Closure::fromCallable($command));
274        $return = $function->getReturnType();
275
276        if (
277            !$return instanceof ReflectionNamedType
278            || $return->getName() !== 'int'
279            || $return->allowsNull()
280        ) {
281            throw new ValueError("Command '{$full}' must declare the return type int");
282        }
283
284        /** @var int Guaranteed by the declared return type under strict_types */
285        return $command(...$this->bind($function, $full, $args));
286    }
287
288    /**
289     * Builds the argument list from the command's parameters.
290     *
291     * Commands may declare any subset of Args and Io in any order;
292     * arguments are matched by declared type. Other parameters are
293     * rejected.
294     *
295     * @return list<Args|Io>
296     */
297    private function bind(ReflectionFunction $function, string $full, Args $args): array
298    {
299        $available = [Args::class => $args, Io::class => $this->io];
300        $bound = [];
301
302        foreach ($function->getParameters() as $parameter) {
303            $type = $parameter->getType();
304            $class = $type instanceof ReflectionNamedType
305            && !$type->allowsNull()
306            && !$parameter->isVariadic()
307                ? $type->getName()
308                : '';
309
310            if ($class !== Args::class && $class !== Io::class) {
311                throw new ValueError(
312                    "Command '{$full}' parameter \${$parameter->getName()} must be declared as Args or Io",
313                );
314            }
315
316            if (!array_key_exists($class, $available)) {
317                $short = $class === Args::class ? 'Args' : 'Io';
318
319                throw new ValueError("Command '{$full}' declares more than one {$short} parameter");
320            }
321
322            $bound[] = $available[$class];
323            unset($available[$class]);
324        }
325
326        return $bound;
327    }
328
329    /**
330     * Checks the provided options against the command's declared `#[Opt]`s
331     * and the positionals against its `#[Arg]`s.
332     *
333     * The declarations are the command's complete interface: undeclared
334     * options and positionals are rejected. Declare a variadic `#[Arg]`
335     * for open-ended input.
336     */
337    private function validate(Entry $entry, Args $args): void
338    {
339        $this->validateOptions($entry, $args);
340        $this->validateArguments($entry, $args);
341    }
342
343    private function validateOptions(Entry $entry, Args $args): void
344    {
345        $opts = $entry->opts();
346        $declared = [];
347
348        foreach ($opts as $opt) {
349            if (
350                array_key_exists($opt->long, $declared)
351                || $opt->short !== '' && array_key_exists($opt->short, $declared)
352            ) {
353                $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short;
354
355                throw new ValueError(
356                    "Command '{$entry->meta->full()}' declares the option name '{$name}' twice",
357                );
358            }
359
360            $declared[$opt->long] = $opt;
361
362            if ($opt->short !== '') {
363                $declared[$opt->short] = $opt;
364            }
365        }
366
367        foreach ($args->names() as $name) {
368            $opt = $declared[$name] ?? null;
369
370            if ($opt === null) {
371                throw new ValueError($this->unknownOption($name, $entry->meta->full(), array_keys($declared)));
372            }
373
374            $values = $args->opts($name);
375
376            if ($opt->value === '' && $values !== []) {
377                throw new ValueError("Option '{$name}' does not accept a value");
378            }
379
380            // Every occurrence needs a value, also when a repetition
381            // provides one: `--host --host=x` hides a bare `--host`.
382            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
383                throw new ValueError("Option '{$name}' requires a value: {$name}=<{$opt->value}>");
384            }
385        }
386    }
387
388    private function validateArguments(Entry $entry, Args $args): void
389    {
390        $declared = $entry->args();
391        $positionals = $args->positionals();
392
393        if ($declared === []) {
394            if ($positionals !== []) {
395                throw new ValueError("Unexpected argument '{$positionals[0]}'");
396            }
397
398            return;
399        }
400
401        $last = count($declared) - 1;
402        $required = 0;
403
404        foreach ($declared as $index => $arg) {
405            if ($arg->variadic && $index < $last) {
406                throw new ValueError(
407                    "Command '{$entry->meta->full()}' declares an argument "
408                    . "after the variadic '<{$arg->name}>'",
409                );
410            }
411
412            if ($arg->optional) {
413                continue;
414            }
415
416            // Required arguments must form a prefix of the declaration.
417            if ($index > $required) {
418                throw new ValueError(
419                    "Command '{$entry->meta->full()}' declares the required argument "
420                    . "'<{$arg->name}>' after an optional one",
421                );
422            }
423
424            $required++;
425        }
426
427        $count = count($positionals);
428
429        if ($count < $required) {
430            throw new ValueError("Missing required argument '<{$declared[$count]->name}>'");
431        }
432
433        // A variadic last argument accepts the remaining positionals.
434        if (!$declared[$last]->variadic && $count > count($declared)) {
435            throw new ValueError("Unexpected argument '{$positionals[count($declared)]}'");
436        }
437    }
438
439    /** @param list<string> $declared */
440    private function unknownOption(string $name, string $full, array $declared): string
441    {
442        if ($name === '--help' || $name === '-h') {
443            $script = $_SERVER['argv'][0] ?? 'run';
444
445            return "Unknown option '{$name}'. Use 'php {$script} help {$full}' to show the command's help";
446        }
447
448        $message = "Unknown option '{$name}'";
449        $best = '';
450        $bestDistance = PHP_INT_MAX;
451
452        foreach ($declared as $candidate) {
453            $distance = levenshtein($name, $candidate);
454
455            if ($distance < $bestDistance) {
456                $bestDistance = $distance;
457                $best = $candidate;
458            }
459        }
460
461        return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message;
462    }
463
464    private function showCommandHelp(Entry $entry): int
465    {
466        new Help($this->io)->show($entry->meta, $entry->opts(), $entry->args());
467
468        return 0;
469    }
470
471    private function echoGroup(string $title): void
472    {
473        $this->io->echo("\n<yellow>{$title}</yellow>\n");
474    }
475
476    private function echoCommand(string $prefix, string $name, string $desc): void
477    {
478        $prefix = $prefix ? $prefix . ':' : '';
479
480        // Pad on the visible length; the markup tags don't print.
481        $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name)));
482        $this->io->echoln("  {$prefix}<green>{$name}</green>{$pad}{$desc}");
483    }
484
485    private function showAmbiguousMessage(string $cmd): int
486    {
487        $this->io->echoErr("Ambiguous command. Please add the group name:\n\n");
488        $entries = $this->list[$cmd];
489        usort($entries, static fn(Entry $a, Entry $b): int => strcmp($a->meta->full(), $b->meta->full()));
490
491        foreach ($entries as $entry) {
492            $this->io->echolnErr("  <yellow>{$entry->meta->prefix}</yellow>:{$entry->meta->name}");
493        }
494
495        return 1;
496    }
497
498    private function getCommand(string $cmd): Entry
499    {
500        // Exact full names resolve first: an unprefixed command wins over
501        // its prefixed namesakes, and prefixed lookups are always exact.
502        // Only then a bare name serves as the alias of a unique prefixed
503        // command; shared by several, it is ambiguous.
504        if (str_contains($cmd, ':')) {
505            /** @var array{0: string, 1: string} $parts */
506            $parts = explode(':', $cmd, limit: 2);
507
508            if (array_key_exists($parts[1], $this->toc[$parts[0]]['commands'] ?? [])) {
509                return $this->toc[$parts[0]]['commands'][$parts[1]];
510            }
511
512            throw new ValueError('Command not found');
513        }
514
515        if (array_key_exists($cmd, $this->toc['']['commands'] ?? [])) {
516            return $this->toc['']['commands'][$cmd];
517        }
518
519        if (array_key_exists($cmd, $this->list)) {
520            if (count($this->list[$cmd]) === 1) {
521                return $this->list[$cmd][0];
522            }
523
524            throw new ValueError('Ambiguous command', self::AMBIGUOUS);
525        }
526
527        throw new ValueError('Command not found');
528    }
529}