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

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
47        array|object|string $commands = [],
48        private readonly Io $io = new Io(),
49        private bool $debug = false,
50        ?callable $resolve = null,
51    ) {
52        $this->resolve = $resolve === null ? null : Closure::fromCallable($resolve);
52        $this->resolve = $resolve === null ? null : Closure::fromCallable($resolve);
52        $this->resolve = $resolve === null ? null : Closure::fromCallable($resolve);
52        $this->resolve = $resolve === null ? null : Closure::fromCallable($resolve);
53        $this->add($commands);
54    }
Runner->add
56    public function add(array|object|string $commands): self
57    {
58        // Index into copies so a rejected call registers nothing.
59        $toc = $this->toc;
60        $list = $this->list;
61        $longestName = $this->longestName;
62
63        foreach ($this->entries($commands) as $entry) {
63        foreach ($this->entries($commands) as $entry) {
64            $meta = $entry->meta;
65
66            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
66            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
66            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
66            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
66            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
67                throw new ValueError("Command name '{$meta->name}' is reserved");
70            if (array_key_exists($meta->name, $toc[$meta->prefix] ?? [])) {
71                throw new ValueError("Duplicate command '{$meta->full()}'");
74            $toc[$meta->prefix][$meta->name] = $entry;
75            $list[$meta->name][] = $entry;
76            $longestName = max($longestName, strlen($meta->full()));
76            $longestName = max($longestName, strlen($meta->full()));
76            $longestName = max($longestName, strlen($meta->full()));
63        foreach ($this->entries($commands) as $entry) {
64            $meta = $entry->meta;
65
66            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
67                throw new ValueError("Command name '{$meta->name}' is reserved");
68            }
69
70            if (array_key_exists($meta->name, $toc[$meta->prefix] ?? [])) {
71                throw new ValueError("Duplicate command '{$meta->full()}'");
72            }
73
74            $toc[$meta->prefix][$meta->name] = $entry;
75            $list[$meta->name][] = $entry;
76            $longestName = max($longestName, strlen($meta->full()));
63        foreach ($this->entries($commands) as $entry) {
64            $meta = $entry->meta;
65
66            if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) {
67                throw new ValueError("Command name '{$meta->name}' is reserved");
68            }
69
70            if (array_key_exists($meta->name, $toc[$meta->prefix] ?? [])) {
71                throw new ValueError("Duplicate command '{$meta->full()}'");
72            }
73
74            $toc[$meta->prefix][$meta->name] = $entry;
75            $list[$meta->name][] = $entry;
76            $longestName = max($longestName, strlen($meta->full()));
77        }
78
79        $this->toc = $toc;
80        $this->list = $list;
81        $this->longestName = $longestName;
82
83        return $this;
84    }
Runner->dispatch
230    private function dispatch(string $arg, bool $isHelpCall, array $tokens, string $script): int
231    {
232        // `help --help` asks for the help of `help`.
233        $cmd = $arg === '--help' || $arg === '-h' ? 'help' : strtolower($arg);
233        $cmd = $arg === '--help' || $arg === '-h' ? 'help' : strtolower($arg);
233        $cmd = $arg === '--help' || $arg === '-h' ? 'help' : strtolower($arg);
233        $cmd = $arg === '--help' || $arg === '-h' ? 'help' : strtolower($arg);
233        $cmd = $arg === '--help' || $arg === '-h' ? 'help' : strtolower($arg);
233        $cmd = $arg === '--help' || $arg === '-h' ? 'help' : strtolower($arg);
234        $builtin = self::BUILTINS[$cmd] ?? null;
235
236        if ($builtin !== null) {
238            if ($isHelpCall || in_array('--help', $tokens, strict: true) || in_array('-h', $tokens, strict: true)) {
238            if ($isHelpCall || in_array('--help', $tokens, strict: true) || in_array('-h', $tokens, strict: true)) {
238            if ($isHelpCall || in_array('--help', $tokens, strict: true) || in_array('-h', $tokens, strict: true)) {
238            if ($isHelpCall || in_array('--help', $tokens, strict: true) || in_array('-h', $tokens, strict: true)) {
238            if ($isHelpCall || in_array('--help', $tokens, strict: true) || in_array('-h', $tokens, strict: true)) {
239                new Help($this->io, $script)->showSummary(...$builtin);
240
241                return 0;
244            return $this->showCommands();
247        try {
248            $entry = $this->getCommand($cmd);
249        } catch (InvalidUsage $e) {
250            if ($e->getCode() === self::AMBIGUOUS) {
251                return $this->showAmbiguousMessage($cmd);
254            throw $e;
257        return $isHelpCall ? $this->showCommandHelp($entry, $script) : $this->runCommand($entry, $tokens, $script);
257        return $isHelpCall ? $this->showCommandHelp($entry, $script) : $this->runCommand($entry, $tokens, $script);
257        return $isHelpCall ? $this->showCommandHelp($entry, $script) : $this->runCommand($entry, $tokens, $script);
257        return $isHelpCall ? $this->showCommandHelp($entry, $script) : $this->runCommand($entry, $tokens, $script);
258    }
Runner->entries
387    private function entries(array|object|string $commands): array
388    {
389        if (!is_array($commands)) {
390            return [$this->entry($commands)];
393        $entries = [];
394
395        foreach ($commands as $key => $item) {
395        foreach ($commands as $key => $item) {
395        foreach ($commands as $key => $item) {
396            if (is_string($key)) {
397                if (!$item instanceof Closure) {
398                    throw new ValueError("Factory for command class '{$key}' must be a closure");
401                $entries[] = Entry::fromFactory($this->validClass($key), $item);
402
403                continue;
406            if (!is_object($item) && !is_string($item)) {
406            if (!is_object($item) && !is_string($item)) {
406            if (!is_object($item) && !is_string($item)) {
407                throw new ValueError('Invalid command registration');
395        foreach ($commands as $key => $item) {
396            if (is_string($key)) {
397                if (!$item instanceof Closure) {
398                    throw new ValueError("Factory for command class '{$key}' must be a closure");
399                }
400
401                $entries[] = Entry::fromFactory($this->validClass($key), $item);
402
403                continue;
404            }
405
406            if (!is_object($item) && !is_string($item)) {
407                throw new ValueError('Invalid command registration');
408            }
409
410            $entries[] = $this->entry($item);
395        foreach ($commands as $key => $item) {
396            if (is_string($key)) {
397                if (!$item instanceof Closure) {
398                    throw new ValueError("Factory for command class '{$key}' must be a closure");
399                }
400
401                $entries[] = Entry::fromFactory($this->validClass($key), $item);
402
403                continue;
404            }
405
406            if (!is_object($item) && !is_string($item)) {
407                throw new ValueError('Invalid command registration');
408            }
409
410            $entries[] = $this->entry($item);
411        }
412
413        return $entries;
414    }
Runner->entry
416    private function entry(object|string $command): Entry
417    {
418        if ($command instanceof Closure) {
419            throw new ValueError(
420                'Closure commands are not supported; use an anonymous class with a #[Command] attribute',
424        if (is_object($command)) {
425            return Entry::fromInstance($command);
428        $class = $this->validClass($command);
429        $resolve = $this->resolve;
430
431        return $resolve === null
432            ? Entry::fromClass($class)
433            : Entry::fromFactory($class, static fn(): mixed => $resolve($class));
433            : Entry::fromFactory($class, static fn(): mixed => $resolve($class));
434    }
Runner->getCommand
349    private function getCommand(string $cmd): Entry
350    {
351        // Exact full names resolve first: an unprefixed command wins over
352        // its prefixed namesakes, and prefixed lookups are always exact.
353        // Only then a bare name serves as the alias of a unique prefixed
354        // command; shared by several, it is ambiguous.
355        if (str_contains($cmd, ':')) {
355        if (str_contains($cmd, ':')) {
355        if (str_contains($cmd, ':')) {
355        if (str_contains($cmd, ':')) {
357            $parts = explode(':', $cmd, limit: 2);
358
359            return $this->toc[$parts[0]][$parts[1]] ?? throw $this->notFound($cmd);
362        $entry = $this->toc[''][$cmd] ?? null;
363
364        if ($entry !== null) {
365            return $entry;
368        if (array_key_exists($cmd, $this->list)) {
369            if (count($this->list[$cmd]) === 1) {
370                return $this->list[$cmd][0];
373            throw new InvalidUsage('Ambiguous command', self::AMBIGUOUS);
376        throw $this->notFound($cmd);
377    }
Runner->message
263    private function message(Throwable $e): string
264    {
265        return $e->getMessage() === '' ? $e::class : $e->getMessage();
265        return $e->getMessage() === '' ? $e::class : $e->getMessage();
265        return $e->getMessage() === '' ? $e::class : $e->getMessage();
265        return $e->getMessage() === '' ? $e::class : $e->getMessage();
266    }
Runner->names
154        $list = [];
155
156        foreach ($this->toc as $commands) {
156        foreach ($this->toc as $commands) {
157            foreach ($commands as $entry) {
157            foreach ($commands as $entry) {
158                $meta = $entry->meta;
159
160                // Full names are unique; only bare names can be shared.
161                if ($meta->prefix !== '') {
162                    $list[$meta->full()] = 1;
163                }
164
165                $list[$meta->name] = ($list[$meta->name] ?? 0) + 1;
157            foreach ($commands as $entry) {
158                $meta = $entry->meta;
159
160                // Full names are unique; only bare names can be shared.
161                if ($meta->prefix !== '') {
162                    $list[$meta->full()] = 1;
163                }
164
165                $list[$meta->name] = ($list[$meta->name] ?? 0) + 1;
156        foreach ($this->toc as $commands) {
157            foreach ($commands as $entry) {
156        foreach ($this->toc as $commands) {
157            foreach ($commands as $entry) {
158                $meta = $entry->meta;
159
160                // Full names are unique; only bare names can be shared.
161                if ($meta->prefix !== '') {
162                    $list[$meta->full()] = 1;
163                }
164
165                $list[$meta->name] = ($list[$meta->name] ?? 0) + 1;
166            }
167        }
168
169        // The built-ins resolve before a command sharing their name.
170        $list['commands'] = 1;
171        $list['help'] = 1;
172
173        ksort($list);
174        $names = [];
175
176        foreach ($list as $name => $count) {
176        foreach ($list as $name => $count) {
176        foreach ($list as $name => $count) {
177            if ($count === 1 || array_key_exists($name, $this->toc[''] ?? [])) {
177            if ($count === 1 || array_key_exists($name, $this->toc[''] ?? [])) {
177            if ($count === 1 || array_key_exists($name, $this->toc[''] ?? [])) {
176        foreach ($list as $name => $count) {
177            if ($count === 1 || array_key_exists($name, $this->toc[''] ?? [])) {
178                // PHP turns numeric keys like '2026' into integers.
179                $names[] = (string) $name;
176        foreach ($list as $name => $count) {
176        foreach ($list as $name => $count) {
177            if ($count === 1 || array_key_exists($name, $this->toc[''] ?? [])) {
178                // PHP turns numeric keys like '2026' into integers.
179                $names[] = (string) $name;
180            }
181        }
182
183        return $names;
184    }
Runner->notFound
379    private function notFound(string $cmd): InvalidUsage
380    {
381        $name = Suggestion::find($cmd, $this->names());
382
383        return new InvalidUsage($name === null ? 'Command not found' : "Command not found. Did you mean '{$name}'?");
383        return new InvalidUsage($name === null ? 'Command not found' : "Command not found. Did you mean '{$name}'?");
383        return new InvalidUsage($name === null ? 'Command not found' : "Command not found. Did you mean '{$name}'?");
383        return new InvalidUsage($name === null ? 'Command not found' : "Command not found. Did you mean '{$name}'?");
384    }
Runner->run
193    public function run(?array $argv = null): int
194    {
195        $argv ??= $_SERVER['argv'] ?? [];
196        $script = $argv[0] ?? '';
197        $arg = $argv[1] ?? null;
198        $isHelpCall = $arg === '--help' || $arg === '-h' || strtolower($arg ?? '') === 'help';
198        $isHelpCall = $arg === '--help' || $arg === '-h' || strtolower($arg ?? '') === 'help';
198        $isHelpCall = $arg === '--help' || $arg === '-h' || strtolower($arg ?? '') === 'help';
198        $isHelpCall = $arg === '--help' || $arg === '-h' || strtolower($arg ?? '') === 'help';
198        $isHelpCall = $arg === '--help' || $arg === '-h' || strtolower($arg ?? '') === 'help';
199
200        if ($isHelpCall) {
201            $arg = $argv[2] ?? null;
202        }
203
204        try {
204        try {
205            return $arg === null
206                ? $this->showHelp($script)
207                : $this->dispatch($arg, $isHelpCall, array_slice($argv, offset: 2), $script);
207                : $this->dispatch($arg, $isHelpCall, array_slice($argv, offset: 2), $script);
208        } catch (Throwable $e) {
210            $this->io->error(
211                "Error while running command '%s':\n\n%s",
212                $arg ?? '<no command given>',
213                $this->message($e),
214            );
215
216            // A usage error is the input's fault; its trace shows nothing.
217            if ($this->debug && !$e instanceof InvalidUsage) {
217            if ($this->debug && !$e instanceof InvalidUsage) {
217            if ($this->debug && !$e instanceof InvalidUsage) {
218                $this->showTraceback($e);
219            }
220
221            return $e instanceof InvalidUsage ? 2 : 1;
221            return $e instanceof InvalidUsage ? 2 : 1;
221            return $e instanceof InvalidUsage ? 2 : 1;
221            return $e instanceof InvalidUsage ? 2 : 1;
221            return $e instanceof InvalidUsage ? 2 : 1;
222        }
223    }
Runner->runCommand
291    private function runCommand(Entry $entry, array $tokens, string $script): int
292    {
293        $signature = $entry->signature();
294
295        if ($signature->asksForHelp($tokens)) {
296            return $this->showCommandHelp($entry, $script);
299        $values = $signature->bind($tokens, $this->io);
300
301        // The signature checked that __invoke() exists; PHP requires it
302        // to be public.
303        /** @var callable-object $command */
304        $command = $entry->command();
305
306        /** @var int Guaranteed by the declared return type under strict_types */
307        return $command(...$signature->match($values, $command));
308    }
Runner->showAmbiguousMessage
336    private function showAmbiguousMessage(string $cmd): int
337    {
338        $this->io->error("Ambiguous command. Please add the group name:\n");
339        $entries = $this->list[$cmd];
340        usort($entries, static fn(Entry $a, Entry $b): int => strcmp($a->meta->full(), $b->meta->full()));
341
342        foreach ($entries as $entry) {
342        foreach ($entries as $entry) {
342        foreach ($entries as $entry) {
343            $this->io->error('  <yellow>%s</yellow>:%s', $entry->meta->prefix, $entry->meta->name);
342        foreach ($entries as $entry) {
343            $this->io->error('  <yellow>%s</yellow>:%s', $entry->meta->prefix, $entry->meta->name);
344        }
345
346        return 2;
347    }
Runner->showCommand
325    private function showCommand(string $prefix, string $name, string $desc): void
326    {
327        $prefix = $prefix === '' ? '' : $prefix . ':';
327        $prefix = $prefix === '' ? '' : $prefix . ':';
327        $prefix = $prefix === '' ? '' : $prefix . ':';
327        $prefix = $prefix === '' ? '' : $prefix . ':';
328
329        // The longest name includes every listed one, so the gap is at
330        // least 2.
331        $pad = str_repeat(' ', $this->longestName + 2 - strlen($prefix . $name));
332        $this->io->write('  %s<green>%s</green>%s', $prefix, $name, $pad);
333        $this->io->line($desc);
334    }
Runner->showCommandHelp
310    private function showCommandHelp(Entry $entry, string $script): int
311    {
312        new Help($this->io, $script)->showFor($entry->class);
313
314        return 0;
315    }
Runner->showCommands
134        foreach ($this->names() as $name) {
134        foreach ($this->names() as $name) {
134        foreach ($this->names() as $name) {
135            $this->io->line('%s', $name);
134        foreach ($this->names() as $name) {
135            $this->io->line('%s', $name);
136        }
137
138        return 0;
139    }
Runner->showGroup
317    private function showGroup(string $title): void
318    {
319        $this->io->line("\n<yellow>%s</yellow>", $title);
320    }
Runner->showHelp
86    private function showHelp(string $script): int
87    {
88        $this->io->line('<yellow>Usage:</yellow>');
89        $this->io->line("  php %s [prefix:]command [arguments]\n", $script);
90        $this->io->line("Prefixes are optional if the command is unambiguous.\n");
91        $this->io->line('Available commands:');
92
93        /** @var array<array-key, array<string, Entry>> $sections */
94        $sections = [];
95
96        foreach ($this->toc as $commands) {
96        foreach ($this->toc as $commands) {
97            foreach ($commands as $entry) {
97            foreach ($commands as $entry) {
97            foreach ($commands as $entry) {
98                $sections[$entry->meta->title()][$entry->meta->full()] = $entry;
96        foreach ($this->toc as $commands) {
97            foreach ($commands as $entry) {
96        foreach ($this->toc as $commands) {
97            foreach ($commands as $entry) {
98                $sections[$entry->meta->title()][$entry->meta->full()] = $entry;
99            }
100        }
101
102        $this->showGroup('General');
103        $this->showCommand('', 'commands', 'Lists all available commands');
104        $this->showCommand('', 'help', 'Displays this overview');
105        $this->showSection($sections['General'] ?? []);
106        unset($sections['General']);
107
108        // PHP turns numeric keys like '2026' into integers.
109        uksort($sections, static fn(int|string $a, int|string $b): int => strcasecmp((string) $a, (string) $b));
110
111        foreach ($sections as $title => $entries) {
111        foreach ($sections as $title => $entries) {
111        foreach ($sections as $title => $entries) {
111        foreach ($sections as $title => $entries) {
112            $this->showGroup((string) $title);
113            $this->showSection($entries);
114        }
115
116        return 0;
117    }
Runner->showSection
120    private function showSection(array $entries): void
121    {
122        ksort($entries, SORT_STRING);
123
124        foreach ($entries as $entry) {
124        foreach ($entries as $entry) {
124        foreach ($entries as $entry) {
125            $this->showCommand($entry->meta->prefix, $entry->meta->name, $entry->meta->description);
124        foreach ($entries as $entry) {
125            $this->showCommand($entry->meta->prefix, $entry->meta->name, $entry->meta->description);
126        }
127    }
Runner->showTraceback
272    private function showTraceback(Throwable $e): void
273    {
274        $io = $this->io->stderr();
275        $io->line("\n<yellow>Traceback:</yellow>");
276
277        while (true) {
278            $io->line('%s in %s:%d', $e::class, $e->getFile(), $e->getLine());
279            $io->line('%s', $e->getTraceAsString());
280            $e = $e->getPrevious();
281
282            if ($e === null) {
283                return;
277        while (true) {
278            $io->line('%s in %s:%d', $e::class, $e->getFile(), $e->getLine());
279            $io->line('%s', $e->getTraceAsString());
280            $e = $e->getPrevious();
281
282            if ($e === null) {
283                return;
284            }
285
286            $io->line("\n<yellow>Caused by:</yellow> %s", $this->message($e));
277        while (true) {
288    }
Runner->validClass
437    private function validClass(string $class): string
438    {
439        if (!class_exists($class)) {
439        if (!class_exists($class)) {
439        if (!class_exists($class)) {
439        if (!class_exists($class)) {
440            throw new ValueError("Unknown command class '{$class}'");
443        return $class;
444    }
{closure:/workspace/celema/console/src/Runner.php:109-109}
109        uksort($sections, static fn(int|string $a, int|string $b): int => strcasecmp((string) $a, (string) $b));
{closure:/workspace/celema/console/src/Runner.php:340-340}
340        usort($entries, static fn(Entry $a, Entry $b): int => strcmp($a->meta->full(), $b->meta->full()));
{closure:/workspace/celema/console/src/Runner.php:433-433}
433            : Entry::fromFactory($class, static fn(): mixed => $resolve($class));