Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
157 / 157
97.52% covered (success)
97.52%
157 / 161
36.26% covered (danger)
36.26%
66 / 182
80.00% covered (warning)
80.00%
16 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Runner
100.00% covered (success)
100.00%
157 / 157
97.52% covered (success)
97.52%
157 / 161
36.26% covered (danger)
36.26%
66 / 182
100.00% covered (success)
100.00%
20 / 20
1531.41
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
 add
100.00% covered (success)
100.00%
16 / 16
93.33% covered (success)
93.33%
14 / 15
42.86% covered (danger)
42.86%
6 / 14
100.00% covered (success)
100.00%
1 / 1
12.72
 showHelp
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
10 / 10
20.00% covered (danger)
20.00%
3 / 15
100.00% covered (success)
100.00%
1 / 1
12.19
 showSection
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
4 / 4
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
2.15
 showCommands
100.00% covered (success)
100.00%
3 / 3
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
 names
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
16 / 16
5.56% covered (danger)
5.56%
2 / 36
100.00% covered (success)
100.00%
1 / 1
48.28
 run
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
19 / 19
50.00% covered (danger)
50.00%
12 / 24
100.00% covered (success)
100.00%
1 / 1
19.12
 dispatch
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
22 / 22
23.81% covered (danger)
23.81%
10 / 42
100.00% covered (success)
100.00%
1 / 1
54.23
 message
100.00% covered (success)
100.00%
1 / 1
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
 showTraceback
100.00% covered (success)
100.00%
9 / 9
83.33% covered (warning)
83.33%
5 / 6
33.33% covered (danger)
33.33%
1 / 3
100.00% covered (success)
100.00%
1 / 1
5.67
 runCommand
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 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
 showGroup
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
 showCommand
100.00% covered (success)
100.00%
4 / 4
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
 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%
11 / 11
91.67% covered (success)
91.67%
11 / 12
50.00% covered (danger)
50.00%
5 / 10
100.00% covered (success)
100.00%
1 / 1
8.12
 notFound
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
 entries
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
14 / 14
55.56% covered (warning)
55.56%
5 / 9
100.00% covered (success)
100.00%
1 / 1
11.30
 entry
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 validClass
100.00% covered (success)
100.00%
3 / 3
83.33% covered (warning)
83.33%
5 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
2.50
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Console;
6
7use Celema\Console\Exception\InvalidUsage;
8use Closure;
9use Throwable;
10use ValueError;
11
12/**
13 * @api
14 */
15final class Runner
16{
17    private const int AMBIGUOUS = 1;
18
19    /** The built-in commands: the description and usage of their help screen. */
20    private const array BUILTINS = [
21        'commands' => ['Lists all available commands', 'commands'],
22        'help' => ['Displays the overview of all commands, or the help of a command', 'help [<command>]'],
23    ];
24
25    /**
26     * The commands indexed by prefix and name.
27     *
28     * @var array<string, array<string, Entry>>
29     */
30    private array $toc = [];
31
32    /**
33     * The commands indexed by name only.
34     *
35     * @var array<string, list<Entry>>
36     */
37    private array $list = [];
38
39    /** The widest listed name; the built-in `commands` sets the minimum. */
40    private int $longestName = 8;
41
42    /** @var null|Closure(class-string): object */
43    private readonly ?Closure $resolve;
44
45    /** @param null|callable(class-string): object $resolve */
46    public function __construct(
47        array|object|string $commands = [],
48        private readonly Io $io = new Io(),
49        private bool $debug = false,
50        ?callable $resolve = null,
51    ) {
52        $this->resolve = $resolve === null ? null : Closure::fromCallable($resolve);
53        $this->add($commands);
54    }
55
56    public function add(array|object|string $commands): self
57    {
58        // Index into copies so a rejected call registers nothing.
59        $toc = $this->toc;
60        $list = $this->list;
61        $longestName = $this->longestName;
62
63        foreach ($this->entries($commands) as $entry) {
64            $meta = $entry->meta;
65
66            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
67                throw new ValueError("Command name '{$meta->name}' is reserved");
68            }
69
70            if (array_key_exists($meta->name, $toc[$meta->prefix] ?? [])) {
71                throw new ValueError("Duplicate command '{$meta->full()}'");
72            }
73
74            $toc[$meta->prefix][$meta->name] = $entry;
75            $list[$meta->name][] = $entry;
76            $longestName = max($longestName, strlen($meta->full()));
77        }
78
79        $this->toc = $toc;
80        $this->list = $list;
81        $this->longestName = $longestName;
82
83        return $this;
84    }
85
86    private function showHelp(string $script): int
87    {
88        $this->io->line('<yellow>Usage:</yellow>');
89        $this->io->line("  php %s [prefix:]command [arguments]\n", $script);
90        $this->io->line("Prefixes are optional if the command is unambiguous.\n");
91        $this->io->line('Available commands:');
92
93        /** @var array<array-key, array<string, Entry>> $sections */
94        $sections = [];
95
96        foreach ($this->toc as $commands) {
97            foreach ($commands as $entry) {
98                $sections[$entry->meta->title()][$entry->meta->full()] = $entry;
99            }
100        }
101
102        $this->showGroup('General');
103        $this->showCommand('', 'commands', 'Lists all available commands');
104        $this->showCommand('', 'help', 'Displays this overview');
105        $this->showSection($sections['General'] ?? []);
106        unset($sections['General']);
107
108        // PHP turns numeric keys like '2026' into integers.
109        uksort($sections, static fn(int|string $a, int|string $b): int => strcasecmp((string) $a, (string) $b));
110
111        foreach ($sections as $title => $entries) {
112            $this->showGroup((string) $title);
113            $this->showSection($entries);
114        }
115
116        return 0;
117    }
118
119    /** @param array<string, Entry> $entries Keyed by full name */
120    private function showSection(array $entries): void
121    {
122        ksort($entries, SORT_STRING);
123
124        foreach ($entries as $entry) {
125            $this->showCommand($entry->meta->prefix, $entry->meta->name, $entry->meta->description);
126        }
127    }
128
129    /**
130     * Displays a list of all available commands.
131     */
132    private function showCommands(): int
133    {
134        foreach ($this->names() as $name) {
135            $this->io->line('%s', $name);
136        }
137
138        return 0;
139    }
140
141    /**
142     * The names that resolve to a command, sorted.
143     *
144     * With and without namespace/group. If a bare name is shared, e. g.
145     * by foo:cmd and bar:cmd, only the namespaced forms are included â€”
146     * unless the bare name belongs to an unprefixed command, which always
147     * resolves exactly.
148     *
149     * @return list<string>
150     */
151    private function names(): array
152    {
153        /** @var array<array-key, int> $list */
154        $list = [];
155
156        foreach ($this->toc as $commands) {
157            foreach ($commands as $entry) {
158                $meta = $entry->meta;
159
160                // Full names are unique; only bare names can be shared.
161                if ($meta->prefix !== '') {
162                    $list[$meta->full()] = 1;
163                }
164
165                $list[$meta->name] = ($list[$meta->name] ?? 0) + 1;
166            }
167        }
168
169        // The built-ins resolve before a command sharing their name.
170        $list['commands'] = 1;
171        $list['help'] = 1;
172
173        ksort($list);
174        $names = [];
175
176        foreach ($list as $name => $count) {
177            if ($count === 1 || array_key_exists($name, $this->toc[''] ?? [])) {
178                // PHP turns numeric keys like '2026' into integers.
179                $names[] = (string) $name;
180            }
181        }
182
183        return $names;
184    }
185
186    /**
187     * Runs the command named in the argument vector, `$_SERVER['argv']` by
188     * default. A given vector has the same shape: it starts with the script
189     * name, which help screens and usage hints display.
190     *
191     * @param null|list<string> $argv
192     */
193    public function run(?array $argv = null): int
194    {
195        $argv ??= $_SERVER['argv'] ?? [];
196        $script = $argv[0] ?? '';
197        $arg = $argv[1] ?? null;
198        $isHelpCall = $arg === '--help' || $arg === '-h' || strtolower($arg ?? '') === 'help';
199
200        if ($isHelpCall) {
201            $arg = $argv[2] ?? null;
202        }
203
204        try {
205            return $arg === null
206                ? $this->showHelp($script)
207                : $this->dispatch($arg, $isHelpCall, array_slice($argv, offset: 2), $script);
208        } catch (Throwable $e) {
209            // `$arg` names the effective target, e.g. `x` for `help x`.
210            $this->io->error(
211                "Error while running command '%s':\n\n%s",
212                $arg ?? '<no command given>',
213                $this->message($e),
214            );
215
216            // A usage error is the input's fault; its trace shows nothing.
217            if ($this->debug && !$e instanceof InvalidUsage) {
218                $this->showTraceback($e);
219            }
220
221            return $e instanceof InvalidUsage ? 2 : 1;
222        }
223    }
224
225    /**
226     * Runs the command `$arg` names, or shows its help in a help call.
227     *
228     * @param list<string> $tokens
229     */
230    private function dispatch(string $arg, bool $isHelpCall, array $tokens, string $script): int
231    {
232        // `help --help` asks for the help of `help`.
233        $cmd = $arg === '--help' || $arg === '-h' ? 'help' : strtolower($arg);
234        $builtin = self::BUILTINS[$cmd] ?? null;
235
236        if ($builtin !== null) {
237            // Only `commands` gets here without a help call.
238            if ($isHelpCall || in_array('--help', $tokens, strict: true) || in_array('-h', $tokens, strict: true)) {
239                new Help($this->io, $script)->showSummary(...$builtin);
240
241                return 0;
242            }
243
244            return $this->showCommands();
245        }
246
247        try {
248            $entry = $this->getCommand($cmd);
249        } catch (InvalidUsage $e) {
250            if ($e->getCode() === self::AMBIGUOUS) {
251                return $this->showAmbiguousMessage($cmd);
252            }
253
254            throw $e;
255        }
256
257        return $isHelpCall ? $this->showCommandHelp($entry, $script) : $this->runCommand($entry, $tokens, $script);
258    }
259
260    /**
261     * The class stands in for a missing message.
262     */
263    private function message(Throwable $e): string
264    {
265        return $e->getMessage() === '' ? $e::class : $e->getMessage();
266    }
267
268    /**
269     * Lists where the exception was thrown and its stack trace, then
270     * the same for each previous exception.
271     */
272    private function showTraceback(Throwable $e): void
273    {
274        $io = $this->io->stderr();
275        $io->line("\n<yellow>Traceback:</yellow>");
276
277        while (true) {
278            $io->line('%s in %s:%d', $e::class, $e->getFile(), $e->getLine());
279            $io->line('%s', $e->getTraceAsString());
280            $e = $e->getPrevious();
281
282            if ($e === null) {
283                return;
284            }
285
286            $io->line("\n<yellow>Caused by:</yellow> %s", $this->message($e));
287        }
288    }
289
290    /** @param list<string> $tokens */
291    private function runCommand(Entry $entry, array $tokens, string $script): int
292    {
293        $signature = $entry->signature();
294
295        if ($signature->asksForHelp($tokens)) {
296            return $this->showCommandHelp($entry, $script);
297        }
298
299        $values = $signature->bind($tokens, $this->io);
300
301        // The signature checked that __invoke() exists; PHP requires it
302        // to be public.
303        /** @var callable-object $command */
304        $command = $entry->command();
305
306        /** @var int Guaranteed by the declared return type under strict_types */
307        return $command(...$signature->match($values, $command));
308    }
309
310    private function showCommandHelp(Entry $entry, string $script): int
311    {
312        new Help($this->io, $script)->showFor($entry->class);
313
314        return 0;
315    }
316
317    private function showGroup(string $title): void
318    {
319        $this->io->line("\n<yellow>%s</yellow>", $title);
320    }
321
322    /**
323     * The description is markup; the names are data.
324     */
325    private function showCommand(string $prefix, string $name, string $desc): void
326    {
327        $prefix = $prefix === '' ? '' : $prefix . ':';
328
329        // The longest name includes every listed one, so the gap is at
330        // least 2.
331        $pad = str_repeat(' ', $this->longestName + 2 - strlen($prefix . $name));
332        $this->io->write('  %s<green>%s</green>%s', $prefix, $name, $pad);
333        $this->io->line($desc);
334    }
335
336    private function showAmbiguousMessage(string $cmd): int
337    {
338        $this->io->error("Ambiguous command. Please add the group name:\n");
339        $entries = $this->list[$cmd];
340        usort($entries, static fn(Entry $a, Entry $b): int => strcmp($a->meta->full(), $b->meta->full()));
341
342        foreach ($entries as $entry) {
343            $this->io->error('  <yellow>%s</yellow>:%s', $entry->meta->prefix, $entry->meta->name);
344        }
345
346        return 2;
347    }
348
349    private function getCommand(string $cmd): Entry
350    {
351        // Exact full names resolve first: an unprefixed command wins over
352        // its prefixed namesakes, and prefixed lookups are always exact.
353        // Only then a bare name serves as the alias of a unique prefixed
354        // command; shared by several, it is ambiguous.
355        if (str_contains($cmd, ':')) {
356            /** @var array{0: string, 1: string} $parts */
357            $parts = explode(':', $cmd, limit: 2);
358
359            return $this->toc[$parts[0]][$parts[1]] ?? throw $this->notFound($cmd);
360        }
361
362        $entry = $this->toc[''][$cmd] ?? null;
363
364        if ($entry !== null) {
365            return $entry;
366        }
367
368        if (array_key_exists($cmd, $this->list)) {
369            if (count($this->list[$cmd]) === 1) {
370                return $this->list[$cmd][0];
371            }
372
373            throw new InvalidUsage('Ambiguous command', self::AMBIGUOUS);
374        }
375
376        throw $this->notFound($cmd);
377    }
378
379    private function notFound(string $cmd): InvalidUsage
380    {
381        $name = Suggestion::find($cmd, $this->names());
382
383        return new InvalidUsage($name === null ? 'Command not found' : "Command not found. Did you mean '{$name}'?");
384    }
385
386    /** @return list<Entry> */
387    private function entries(array|object|string $commands): array
388    {
389        if (!is_array($commands)) {
390            return [$this->entry($commands)];
391        }
392
393        $entries = [];
394
395        foreach ($commands as $key => $item) {
396            if (is_string($key)) {
397                if (!$item instanceof Closure) {
398                    throw new ValueError("Factory for command class '{$key}' must be a closure");
399                }
400
401                $entries[] = Entry::fromFactory($this->validClass($key), $item);
402
403                continue;
404            }
405
406            if (!is_object($item) && !is_string($item)) {
407                throw new ValueError('Invalid command registration');
408            }
409
410            $entries[] = $this->entry($item);
411        }
412
413        return $entries;
414    }
415
416    private function entry(object|string $command): Entry
417    {
418        if ($command instanceof Closure) {
419            throw new ValueError(
420                'Closure commands are not supported; use an anonymous class with a #[Command] attribute',
421            );
422        }
423
424        if (is_object($command)) {
425            return Entry::fromInstance($command);
426        }
427
428        $class = $this->validClass($command);
429        $resolve = $this->resolve;
430
431        return $resolve === null
432            ? Entry::fromClass($class)
433            : Entry::fromFactory($class, static fn(): mixed => $resolve($class));
434    }
435
436    /** @return class-string */
437    private function validClass(string $class): string
438    {
439        if (!class_exists($class)) {
440            throw new ValueError("Unknown command class '{$class}'");
441        }
442
443        return $class;
444    }
445}