Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
225 / 225
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%
225 / 225
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%
22 / 22
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 !== ''
352                    && array_key_exists($opt->short, $declared)
353            ) {
354                $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short;
355
356                throw new ValueError(
357                    "Command '{$entry->meta->full()}' declares the option name '{$name}' twice",
358                );
359            }
360
361            $declared[$opt->long] = $opt;
362
363            if ($opt->short !== '') {
364                $declared[$opt->short] = $opt;
365            }
366        }
367
368        foreach ($args->names() as $name) {
369            $opt = $declared[$name] ?? null;
370
371            if ($opt === null) {
372                throw new ValueError($this->unknownOption($name, $entry->meta->full(), array_keys($declared)));
373            }
374
375            $values = $args->opts($name);
376
377            if ($opt->value === '' && $values !== []) {
378                throw new ValueError("Option '{$name}' does not accept a value");
379            }
380
381            // Every occurrence needs a value, also when a repetition
382            // provides one: `--host --host=x` hides a bare `--host`.
383            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
384                throw new ValueError("Option '{$name}' requires a value: {$name}=<{$opt->value}>");
385            }
386        }
387    }
388
389    private function validateArguments(Entry $entry, Args $args): void
390    {
391        $declared = $entry->args();
392        $positionals = $args->positionals();
393
394        if ($declared === []) {
395            if ($positionals !== []) {
396                throw new ValueError("Unexpected argument '{$positionals[0]}'");
397            }
398
399            return;
400        }
401
402        $last = count($declared) - 1;
403        $required = 0;
404
405        foreach ($declared as $index => $arg) {
406            if ($arg->variadic && $index < $last) {
407                throw new ValueError(
408                    "Command '{$entry->meta->full()}' declares an argument "
409                        . "after the variadic '<{$arg->name}>'",
410                );
411            }
412
413            if ($arg->optional) {
414                continue;
415            }
416
417            // Required arguments must form a prefix of the declaration.
418            if ($index > $required) {
419                throw new ValueError(
420                    "Command '{$entry->meta->full()}' declares the required argument "
421                        . "'<{$arg->name}>' after an optional one",
422                );
423            }
424
425            $required++;
426        }
427
428        $count = count($positionals);
429
430        if ($count < $required) {
431            throw new ValueError("Missing required argument '<{$declared[$count]->name}>'");
432        }
433
434        // A variadic last argument accepts the remaining positionals.
435        if (!$declared[$last]->variadic && $count > count($declared)) {
436            throw new ValueError("Unexpected argument '{$positionals[count($declared)]}'");
437        }
438    }
439
440    /** @param list<string> $declared */
441    private function unknownOption(string $name, string $full, array $declared): string
442    {
443        if ($name === '--help' || $name === '-h') {
444            $script = $_SERVER['argv'][0] ?? 'run';
445
446            return "Unknown option '{$name}'. Use 'php {$script} help {$full}' to show the command's help";
447        }
448
449        $message = "Unknown option '{$name}'";
450        $best = '';
451        $bestDistance = PHP_INT_MAX;
452
453        foreach ($declared as $candidate) {
454            $distance = levenshtein($name, $candidate);
455
456            if ($distance < $bestDistance) {
457                $bestDistance = $distance;
458                $best = $candidate;
459            }
460        }
461
462        return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message;
463    }
464
465    private function showCommandHelp(Entry $entry): int
466    {
467        new Help($this->io)->show($entry->meta, $entry->opts(), $entry->args());
468
469        return 0;
470    }
471
472    private function echoGroup(string $title): void
473    {
474        $this->io->echo("\n<yellow>{$title}</yellow>\n");
475    }
476
477    private function echoCommand(string $prefix, string $name, string $desc): void
478    {
479        $prefix = $prefix ? $prefix . ':' : '';
480
481        // Pad on the visible length; the markup tags don't print.
482        $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name)));
483        $this->io->echoln("  {$prefix}<green>{$name}</green>{$pad}{$desc}");
484    }
485
486    private function showAmbiguousMessage(string $cmd): int
487    {
488        $this->io->echoErr("Ambiguous command. Please add the group name:\n\n");
489        $entries = $this->list[$cmd];
490        usort($entries, static fn(Entry $a, Entry $b): int => strcmp($a->meta->full(), $b->meta->full()));
491
492        foreach ($entries as $entry) {
493            $this->io->echolnErr("  <yellow>{$entry->meta->prefix}</yellow>:{$entry->meta->name}");
494        }
495
496        return 1;
497    }
498
499    private function getCommand(string $cmd): Entry
500    {
501        // Exact full names resolve first: an unprefixed command wins over
502        // its prefixed namesakes, and prefixed lookups are always exact.
503        // Only then a bare name serves as the alias of a unique prefixed
504        // command; shared by several, it is ambiguous.
505        if (str_contains($cmd, ':')) {
506            /** @var array{0: string, 1: string} $parts */
507            $parts = explode(':', $cmd, limit: 2);
508
509            if (array_key_exists($parts[1], $this->toc[$parts[0]]['commands'] ?? [])) {
510                return $this->toc[$parts[0]]['commands'][$parts[1]];
511            }
512
513            throw new ValueError('Command not found');
514        }
515
516        if (array_key_exists($cmd, $this->toc['']['commands'] ?? [])) {
517            return $this->toc['']['commands'][$cmd];
518        }
519
520        if (array_key_exists($cmd, $this->list)) {
521            if (count($this->list[$cmd]) === 1) {
522                return $this->list[$cmd][0];
523            }
524
525            throw new ValueError('Ambiguous command', self::AMBIGUOUS);
526        }
527
528        throw new ValueError('Command not found');
529    }
530}

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
477    private function echoCommand(string $prefix, string $name, string $desc): void
478    {
479        $prefix = $prefix ? $prefix . ':' : '';
479        $prefix = $prefix ? $prefix . ':' : '';
479        $prefix = $prefix ? $prefix . ':' : '';
479        $prefix = $prefix ? $prefix . ':' : '';
480
481        // Pad on the visible length; the markup tags don't print.
482        $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name)));
482        $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name)));
482        $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name)));
482        $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name)));
483        $this->io->echoln("  {$prefix}<green>{$name}</green>{$pad}{$desc}");
484    }
Runner->echoGroup
472    private function echoGroup(string $title): void
473    {
474        $this->io->echo("\n<yellow>{$title}</yellow>\n");
475    }
Runner->getCommand
499    private function getCommand(string $cmd): Entry
500    {
501        // Exact full names resolve first: an unprefixed command wins over
502        // its prefixed namesakes, and prefixed lookups are always exact.
503        // Only then a bare name serves as the alias of a unique prefixed
504        // command; shared by several, it is ambiguous.
505        if (str_contains($cmd, ':')) {
505        if (str_contains($cmd, ':')) {
505        if (str_contains($cmd, ':')) {
505        if (str_contains($cmd, ':')) {
507            $parts = explode(':', $cmd, limit: 2);
508
509            if (array_key_exists($parts[1], $this->toc[$parts[0]]['commands'] ?? [])) {
510                return $this->toc[$parts[0]]['commands'][$parts[1]];
513            throw new ValueError('Command not found');
516        if (array_key_exists($cmd, $this->toc['']['commands'] ?? [])) {
517            return $this->toc['']['commands'][$cmd];
520        if (array_key_exists($cmd, $this->list)) {
521            if (count($this->list[$cmd]) === 1) {
522                return $this->list[$cmd][0];
525            throw new ValueError('Ambiguous command', self::AMBIGUOUS);
528        throw new ValueError('Command not found');
529    }
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
486    private function showAmbiguousMessage(string $cmd): int
487    {
488        $this->io->echoErr("Ambiguous command. Please add the group name:\n\n");
489        $entries = $this->list[$cmd];
490        usort($entries, static fn(Entry $a, Entry $b): int => strcmp($a->meta->full(), $b->meta->full()));
491
492        foreach ($entries as $entry) {
492        foreach ($entries as $entry) {
492        foreach ($entries as $entry) {
493            $this->io->echolnErr("  <yellow>{$entry->meta->prefix}</yellow>:{$entry->meta->name}");
492        foreach ($entries as $entry) {
493            $this->io->echolnErr("  <yellow>{$entry->meta->prefix}</yellow>:{$entry->meta->name}");
494        }
495
496        return 1;
497    }
Runner->showCommandHelp
465    private function showCommandHelp(Entry $entry): int
466    {
467        new Help($this->io)->show($entry->meta, $entry->opts(), $entry->args());
468
469        return 0;
470    }
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
441    private function unknownOption(string $name, string $full, array $declared): string
442    {
443        if ($name === '--help' || $name === '-h') {
443        if ($name === '--help' || $name === '-h') {
443        if ($name === '--help' || $name === '-h') {
444            $script = $_SERVER['argv'][0] ?? 'run';
445
446            return "Unknown option '{$name}'. Use 'php {$script} help {$full}' to show the command's help";
449        $message = "Unknown option '{$name}'";
450        $best = '';
451        $bestDistance = PHP_INT_MAX;
452
453        foreach ($declared as $candidate) {
453        foreach ($declared as $candidate) {
454            $distance = levenshtein($name, $candidate);
455
456            if ($distance < $bestDistance) {
453        foreach ($declared as $candidate) {
454            $distance = levenshtein($name, $candidate);
455
456            if ($distance < $bestDistance) {
457                $bestDistance = $distance;
453        foreach ($declared as $candidate) {
453        foreach ($declared as $candidate) {
454            $distance = levenshtein($name, $candidate);
455
456            if ($distance < $bestDistance) {
457                $bestDistance = $distance;
458                $best = $candidate;
459            }
460        }
461
462        return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message;
462        return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message;
462        return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message;
462        return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message;
463    }
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
389    private function validateArguments(Entry $entry, Args $args): void
390    {
391        $declared = $entry->args();
392        $positionals = $args->positionals();
393
394        if ($declared === []) {
395            if ($positionals !== []) {
396                throw new ValueError("Unexpected argument '{$positionals[0]}'");
399            return;
402        $last = count($declared) - 1;
403        $required = 0;
404
405        foreach ($declared as $index => $arg) {
405        foreach ($declared as $index => $arg) {
405        foreach ($declared as $index => $arg) {
406            if ($arg->variadic && $index < $last) {
406            if ($arg->variadic && $index < $last) {
406            if ($arg->variadic && $index < $last) {
407                throw new ValueError(
408                    "Command '{$entry->meta->full()}' declares an argument "
409                        . "after the variadic '<{$arg->name}>'",
413            if ($arg->optional) {
414                continue;
418            if ($index > $required) {
419                throw new ValueError(
420                    "Command '{$entry->meta->full()}' declares the required argument "
421                        . "'<{$arg->name}>' after an optional one",
405        foreach ($declared as $index => $arg) {
406            if ($arg->variadic && $index < $last) {
407                throw new ValueError(
408                    "Command '{$entry->meta->full()}' declares an argument "
409                        . "after the variadic '<{$arg->name}>'",
410                );
411            }
412
413            if ($arg->optional) {
414                continue;
415            }
416
417            // Required arguments must form a prefix of the declaration.
418            if ($index > $required) {
419                throw new ValueError(
420                    "Command '{$entry->meta->full()}' declares the required argument "
421                        . "'<{$arg->name}>' after an optional one",
422                );
423            }
424
425            $required++;
405        foreach ($declared as $index => $arg) {
406            if ($arg->variadic && $index < $last) {
407                throw new ValueError(
408                    "Command '{$entry->meta->full()}' declares an argument "
409                        . "after the variadic '<{$arg->name}>'",
410                );
411            }
412
413            if ($arg->optional) {
414                continue;
415            }
416
417            // Required arguments must form a prefix of the declaration.
418            if ($index > $required) {
419                throw new ValueError(
420                    "Command '{$entry->meta->full()}' declares the required argument "
421                        . "'<{$arg->name}>' after an optional one",
422                );
423            }
424
425            $required++;
426        }
427
428        $count = count($positionals);
429
430        if ($count < $required) {
431            throw new ValueError("Missing required argument '<{$declared[$count]->name}>'");
435        if (!$declared[$last]->variadic && $count > count($declared)) {
435        if (!$declared[$last]->variadic && $count > count($declared)) {
435        if (!$declared[$last]->variadic && $count > count($declared)) {
436            throw new ValueError("Unexpected argument '{$positionals[count($declared)]}'");
438    }
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 !== ''
352                    && array_key_exists($opt->short, $declared)
352                    && array_key_exists($opt->short, $declared)
352                    && array_key_exists($opt->short, $declared)
354                $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short;
354                $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short;
354                $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short;
354                $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short;
355
356                throw new ValueError(
357                    "Command '{$entry->meta->full()}' declares the option name '{$name}' twice",
361            $declared[$opt->long] = $opt;
362
363            if ($opt->short !== '') {
348        foreach ($opts as $opt) {
349            if (
350                array_key_exists($opt->long, $declared)
351                    || $opt->short !== ''
352                    && array_key_exists($opt->short, $declared)
353            ) {
354                $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short;
355
356                throw new ValueError(
357                    "Command '{$entry->meta->full()}' declares the option name '{$name}' twice",
358                );
359            }
360
361            $declared[$opt->long] = $opt;
362
363            if ($opt->short !== '') {
364                $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 !== ''
352                    && array_key_exists($opt->short, $declared)
353            ) {
354                $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short;
355
356                throw new ValueError(
357                    "Command '{$entry->meta->full()}' declares the option name '{$name}' twice",
358                );
359            }
360
361            $declared[$opt->long] = $opt;
362
363            if ($opt->short !== '') {
364                $declared[$opt->short] = $opt;
365            }
366        }
367
368        foreach ($args->names() as $name) {
368        foreach ($args->names() as $name) {
369            $opt = $declared[$name] ?? null;
370
371            if ($opt === null) {
372                throw new ValueError($this->unknownOption($name, $entry->meta->full(), array_keys($declared)));
375            $values = $args->opts($name);
376
377            if ($opt->value === '' && $values !== []) {
377            if ($opt->value === '' && $values !== []) {
377            if ($opt->value === '' && $values !== []) {
378                throw new ValueError("Option '{$name}' does not accept a value");
383            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
383            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
383            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
383            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
383            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
383            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
383            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
384                throw new ValueError("Option '{$name}' requires a value: {$name}=<{$opt->value}>");
368        foreach ($args->names() as $name) {
368        foreach ($args->names() as $name) {
369            $opt = $declared[$name] ?? null;
370
371            if ($opt === null) {
372                throw new ValueError($this->unknownOption($name, $entry->meta->full(), array_keys($declared)));
373            }
374
375            $values = $args->opts($name);
376
377            if ($opt->value === '' && $values !== []) {
378                throw new ValueError("Option '{$name}' does not accept a value");
379            }
380
381            // Every occurrence needs a value, also when a repetition
382            // provides one: `--host --host=x` hides a bare `--host`.
383            if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) {
384                throw new ValueError("Option '{$name}' requires a value: {$name}=<{$opt->value}>");
385            }
386        }
387    }
{closure:/workspace/celema/console/src/Runner.php:490-490}
490        usort($entries, static fn(Entry $a, Entry $b): int => strcmp($a->meta->full(), $b->meta->full()));