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}

Branches

Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

Runner->__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;
46        $this->io = is_string($output) ? new Io($output, $errorOutput) : $output;
46        $this->io = is_string($output) ? new Io($output, $errorOutput) : $output;
46        $this->io = is_string($output) ? new Io($output, $errorOutput) : $output;
47        $this->orderCommands($commands);
48    }
Runner->bind
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) {
302        foreach ($function->getParameters() as $parameter) {
303            $type = $parameter->getType();
304            $class = $type instanceof ReflectionNamedType
305            && !$type->allowsNull()
305            && !$type->allowsNull()
306            && !$parameter->isVariadic()
306            && !$parameter->isVariadic()
307                ? $type->getName()
304            $class = $type instanceof ReflectionNamedType
305            && !$type->allowsNull()
306            && !$parameter->isVariadic()
307                ? $type->getName()
308                : '';
304            $class = $type instanceof ReflectionNamedType
305            && !$type->allowsNull()
306            && !$parameter->isVariadic()
307                ? $type->getName()
308                : '';
309
310            if ($class !== Args::class && $class !== Io::class) {
310            if ($class !== Args::class && $class !== Io::class) {
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",
316            if (!array_key_exists($class, $available)) {
317                $short = $class === Args::class ? 'Args' : 'Io';
317                $short = $class === Args::class ? 'Args' : 'Io';
317                $short = $class === Args::class ? 'Args' : 'Io';
317                $short = $class === Args::class ? 'Args' : 'Io';
318
319                throw new ValueError("Command '{$full}' declares more than one {$short} parameter");
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];
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    }
Runner->echoCommand
476    private function echoCommand(string $prefix, string $name, string $desc): void
477    {
478        $prefix = $prefix ? $prefix . ':' : '';
478        $prefix = $prefix ? $prefix . ':' : '';
478        $prefix = $prefix ? $prefix . ':' : '';
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)));
481        $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name)));
481        $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name)));
481        $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name)));
482        $this->io->echoln("  {$prefix}<green>{$name}</green>{$pad}{$desc}");
483    }
Runner->echoGroup
471    private function echoGroup(string $title): void
472    {
473        $this->io->echo("\n<yellow>{$title}</yellow>\n");
474    }
Runner->getCommand
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, ':')) {
504        if (str_contains($cmd, ':')) {
504        if (str_contains($cmd, ':')) {
504        if (str_contains($cmd, ':')) {
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]];
512            throw new ValueError('Command not found');
515        if (array_key_exists($cmd, $this->toc['']['commands'] ?? [])) {
516            return $this->toc['']['commands'][$cmd];
519        if (array_key_exists($cmd, $this->list)) {
520            if (count($this->list[$cmd]) === 1) {
521                return $this->list[$cmd][0];
524            throw new ValueError('Ambiguous command', self::AMBIGUOUS);
527        throw new ValueError('Command not found');
528    }
Runner->normalizeOptions
225    private function normalizeOptions(Entry $entry, array $tokens): array
226    {
227        $aliases = [];
228
229        foreach ($entry->opts() as $opt) {
229        foreach ($entry->opts() as $opt) {
230            if ($opt->short !== '') {
229        foreach ($entry->opts() as $opt) {
230            if ($opt->short !== '') {
231                $aliases[$opt->short] = $opt->long;
229        foreach ($entry->opts() as $opt) {
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) {
238        foreach ($tokens as $token) {
243            if ($literal || $token === '--') {
243            if ($literal || $token === '--') {
243            if ($literal || $token === '--') {
244                $literal = true;
245                $normalized[] = $token;
246
247                continue;
250            $separator = strpos(haystack: $token, needle: '=');
251            $name = $separator === false
252                ? $token
251            $name = $separator === false
252                ? $token
253                : substr(string: $token, offset: 0, length: $separator);
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));
257                : $long . ($separator === false ? '' : substr(string: $token, offset: $separator));
257                : $long . ($separator === false ? '' : substr(string: $token, offset: $separator));
255            $normalized[] = $long === null
256                ? $token
257                : $long . ($separator === false ? '' : substr(string: $token, offset: $separator));
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
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    }
Runner->orderCommands
50    private function orderCommands(Commands $commands): void
51    {
52        $groups = [];
53
54        foreach ($commands->entries() as $entry) {
54        foreach ($commands->entries() as $entry) {
55            $meta = $entry->meta;
56
57            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
57            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
57            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
57            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
57            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
58                throw new ValueError("Command name '{$meta->name}' is reserved");
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'])) {
68            if (array_key_exists($meta->name, $groups[$meta->prefix]['commands'])) {
69                throw new ValueError("Duplicate command '{$meta->full()}'");
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;
76            $this->longestName = $len > $this->longestName ? $len : $this->longestName;
76            $this->longestName = $len > $this->longestName ? $len : $this->longestName;
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;
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'));
79        $this->longestName = max($this->longestName, strlen('commands'));
79        $this->longestName = max($this->longestName, strlen('commands'));
79        $this->longestName = max($this->longestName, strlen('commands'));
80
81        ksort($groups);
82
83        foreach ($groups as $name => $group) {
83        foreach ($groups as $name => $group) {
83        foreach ($groups as $name => $group) {
83        foreach ($groups as $name => $group) {
84            $commands = $group['commands'];
85            ksort($commands);
86            $group['commands'] = $commands;
87            $this->toc[$name] = $group;
88        }
89    }
Runner->run
159        try {
160            $argv = $_SERVER['argv'] ?? [];
161            $arg = $argv[1] ?? null;
162
163            if ($arg === null) {
164                return $this->showHelp();
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();
178                $cmd = strtolower($arg);
179            }
180
181            if ($cmd === 'commands') {
181            if ($cmd === 'commands') {
182                return $this->showCommands();
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);
194                throw $e;
197            if ($isHelpCall) {
198                return $this->showCommandHelp($entry);
201            $args = new Args($this->normalizeOptions($entry, $tokens));
202
203            return $this->runCommand($entry, $args);
204        } catch (Throwable $e) {
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;
217            return 1;
218        }
219    }
Runner->runCommand
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");
273        $function = new ReflectionFunction(Closure::fromCallable($command));
274        $return = $function->getReturnType();
275
276        if (
277            !$return instanceof ReflectionNamedType
278            || $return->getName() !== 'int'
278            || $return->getName() !== 'int'
279            || $return->allowsNull()
279            || $return->allowsNull()
281            throw new ValueError("Command '{$full}' must declare the return type int");
285        return $command(...$this->bind($function, $full, $args));
286    }
Runner->showAmbiguousMessage
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) {
491        foreach ($entries as $entry) {
491        foreach ($entries as $entry) {
492            $this->io->echolnErr("  <yellow>{$entry->meta->prefix}</yellow>:{$entry->meta->name}");
491        foreach ($entries as $entry) {
492            $this->io->echolnErr("  <yellow>{$entry->meta->prefix}</yellow>:{$entry->meta->name}");
493        }
494
495        return 1;
496    }
Runner->showCommandHelp
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    }
Runner->showCommands
131        $list = [];
132
133        foreach ($this->toc as $group) {
133        foreach ($this->toc as $group) {
134            foreach ($group['commands'] as $entry) {
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;
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;
133        foreach ($this->toc as $group) {
134            foreach ($group['commands'] as $entry) {
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) {
148        foreach ($list as $name => $count) {
148        foreach ($list as $name => $count) {
149            if ($count === 1 || array_key_exists($name, $this->toc['']['commands'] ?? [])) {
149            if ($count === 1 || array_key_exists($name, $this->toc['']['commands'] ?? [])) {
149            if ($count === 1 || array_key_exists($name, $this->toc['']['commands'] ?? [])) {
148        foreach ($list as $name => $count) {
149            if ($count === 1 || array_key_exists($name, $this->toc['']['commands'] ?? [])) {
150                $this->io->echo("{$name}\n");
148        foreach ($list as $name => $count) {
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    }
Runner->showHelp
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) {
102        foreach ($this->toc['']['commands'] ?? [] as $name => $entry) {
102        foreach ($this->toc['']['commands'] ?? [] as $name => $entry) {
102        foreach ($this->toc['']['commands'] ?? [] as $name => $entry) {
103            $this->echoCommand('', $name, $entry->meta->description);
104        }
105
106        foreach ($this->toc as $prefix => $group) {
106        foreach ($this->toc as $prefix => $group) {
106        foreach ($this->toc as $prefix => $group) {
107            if ($prefix === '') {
108                continue;
111            $this->echoGroup($group['title']);
112
113            foreach ($group['commands'] as $name => $entry) {
113            foreach ($group['commands'] as $name => $entry) {
113            foreach ($group['commands'] as $name => $entry) {
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) {
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    }
Runner->unknownOption
440    private function unknownOption(string $name, string $full, array $declared): string
441    {
442        if ($name === '--help' || $name === '-h') {
442        if ($name === '--help' || $name === '-h') {
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";
448        $message = "Unknown option '{$name}'";
449        $best = '';
450        $bestDistance = PHP_INT_MAX;
451
452        foreach ($declared as $candidate) {
452        foreach ($declared as $candidate) {
453            $distance = levenshtein($name, $candidate);
454
455            if ($distance < $bestDistance) {
452        foreach ($declared as $candidate) {
453            $distance = levenshtein($name, $candidate);
454
455            if ($distance < $bestDistance) {
456                $bestDistance = $distance;
452        foreach ($declared as $candidate) {
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;
461        return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message;
461        return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message;
461        return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message;
462    }
Runner->validate
337    private function validate(Entry $entry, Args $args): void
338    {
339        $this->validateOptions($entry, $args);
340        $this->validateArguments($entry, $args);
341    }
Runner->validateArguments
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]}'");
398            return;
401        $last = count($declared) - 1;
402        $required = 0;
403
404        foreach ($declared as $index => $arg) {
404        foreach ($declared as $index => $arg) {
404        foreach ($declared as $index => $arg) {
405            if ($arg->variadic && $index < $last) {
405            if ($arg->variadic && $index < $last) {
405            if ($arg->variadic && $index < $last) {
406                throw new ValueError(
407                    "Command '{$entry->meta->full()}' declares an argument "
408                    . "after the variadic '<{$arg->name}>'",
412            if ($arg->optional) {
413                continue;
417            if ($index > $required) {
418                throw new ValueError(
419                    "Command '{$entry->meta->full()}' declares the required argument "
420                    . "'<{$arg->name}>' after an optional one",
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++;
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}>'");
434        if (!$declared[$last]->variadic && $count > count($declared)) {
434        if (!$declared[$last]->variadic && $count > count($declared)) {
434        if (!$declared[$last]->variadic && $count > count($declared)) {
435            throw new ValueError("Unexpected argument '{$positionals[count($declared)]}'");
437    }
Runner->validateOptions
343    private function validateOptions(Entry $entry, Args $args): void
344    {
345        $opts = $entry->opts();
346        $declared = [];
347
348        foreach ($opts as $opt) {
348        foreach ($opts as $opt) {
350                array_key_exists($opt->long, $declared)
351                || $opt->short !== '' && array_key_exists($opt->short, $declared)
351                || $opt->short !== '' && array_key_exists($opt->short, $declared)
351                || $opt->short !== '' && array_key_exists($opt->short, $declared)
351                || $opt->short !== '' && array_key_exists($opt->short, $declared)
353                $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short;
353                $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short;
353                $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short;
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",
360            $declared[$opt->long] = $opt;
361
362            if ($opt->short !== '') {
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;
348        foreach ($opts as $opt) {
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) {
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)));
374            $values = $args->opts($name);
375
376            if ($opt->value === '' && $values !== []) {
376            if ($opt->value === '' && $values !== []) {
376            if ($opt->value === '' && $values !== []) {
377                throw new ValueError("Option '{$name}' does not accept a value");
382            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
382            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
382            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
382            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
382            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
382            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
382            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
383                throw new ValueError("Option '{$name}' requires a value: {$name}=<{$opt->value}>");
367        foreach ($args->names() as $name) {
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    }
{closure:/workspace/celema/console/src/Runner.php:489-489}
489        usort($entries, static fn(Entry $a, Entry $b): int => strcmp($a->meta->full(), $b->meta->full()));