Code Coverage |
||||||||||||||||
Lines |
Branches |
Paths |
Functions and Methods |
Classes and Traits |
||||||||||||
| Total | |
100.00% |
157 / 157 |
|
97.52% |
157 / 161 |
|
36.26% |
66 / 182 |
|
80.00% |
16 / 20 |
CRAP | |
0.00% |
0 / 1 |
| Runner | |
100.00% |
157 / 157 |
|
97.52% |
157 / 161 |
|
36.26% |
66 / 182 |
|
100.00% |
20 / 20 |
1531.41 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
4 / 4 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| add | |
100.00% |
16 / 16 |
|
93.33% |
14 / 15 |
|
42.86% |
6 / 14 |
|
100.00% |
1 / 1 |
12.72 | |||
| showHelp | |
100.00% |
18 / 18 |
|
100.00% |
10 / 10 |
|
20.00% |
3 / 15 |
|
100.00% |
1 / 1 |
12.19 | |||
| showSection | |
100.00% |
3 / 3 |
|
100.00% |
4 / 4 |
|
66.67% |
2 / 3 |
|
100.00% |
1 / 1 |
2.15 | |||
| showCommands | |
100.00% |
3 / 3 |
|
100.00% |
4 / 4 |
|
33.33% |
1 / 3 |
|
100.00% |
1 / 1 |
3.19 | |||
| names | |
100.00% |
15 / 15 |
|
100.00% |
16 / 16 |
|
5.56% |
2 / 36 |
|
100.00% |
1 / 1 |
48.28 | |||
| run | |
100.00% |
18 / 18 |
|
100.00% |
19 / 19 |
|
50.00% |
12 / 24 |
|
100.00% |
1 / 1 |
19.12 | |||
| dispatch | |
100.00% |
13 / 13 |
|
100.00% |
22 / 22 |
|
23.81% |
10 / 42 |
|
100.00% |
1 / 1 |
54.23 | |||
| message | |
100.00% |
1 / 1 |
|
100.00% |
4 / 4 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| showTraceback | |
100.00% |
9 / 9 |
|
83.33% |
5 / 6 |
|
33.33% |
1 / 3 |
|
100.00% |
1 / 1 |
5.67 | |||
| runCommand | |
100.00% |
6 / 6 |
|
100.00% |
3 / 3 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| showCommandHelp | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| showGroup | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| showCommand | |
100.00% |
4 / 4 |
|
100.00% |
4 / 4 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| showAmbiguousMessage | |
100.00% |
6 / 6 |
|
100.00% |
4 / 4 |
|
33.33% |
1 / 3 |
|
100.00% |
1 / 1 |
3.19 | |||
| getCommand | |
100.00% |
11 / 11 |
|
91.67% |
11 / 12 |
|
50.00% |
5 / 10 |
|
100.00% |
1 / 1 |
8.12 | |||
| notFound | |
100.00% |
2 / 2 |
|
100.00% |
4 / 4 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| entries | |
100.00% |
13 / 13 |
|
100.00% |
14 / 14 |
|
55.56% |
5 / 9 |
|
100.00% |
1 / 1 |
11.30 | |||
| entry | |
100.00% |
11 / 11 |
|
100.00% |
8 / 8 |
|
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| validClass | |
100.00% |
3 / 3 |
|
83.33% |
5 / 6 |
|
50.00% |
2 / 4 |
|
100.00% |
1 / 1 |
2.50 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Celema\Console; |
| 6 | |
| 7 | use Celema\Console\Exception\InvalidUsage; |
| 8 | use Closure; |
| 9 | use Throwable; |
| 10 | use ValueError; |
| 11 | |
| 12 | /** |
| 13 | * @api |
| 14 | */ |
| 15 | final 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 | } |