Code Coverage |
||||||||||||||||
Lines |
Branches |
Paths |
Functions and Methods |
Classes and Traits |
||||||||||||
| Total | |
100.00% |
224 / 224 |
|
98.64% |
218 / 221 |
|
9.93% |
61 / 614 |
|
82.35% |
14 / 17 |
CRAP | |
0.00% |
0 / 1 |
| Runner | |
100.00% |
224 / 224 |
|
98.64% |
218 / 221 |
|
9.93% |
61 / 614 |
|
100.00% |
17 / 17 |
7553.69 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
4 / 4 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| orderCommands | |
100.00% |
23 / 23 |
|
95.65% |
22 / 23 |
|
4.30% |
4 / 93 |
|
100.00% |
1 / 1 |
79.99 | |||
| showHelp | |
100.00% |
17 / 17 |
|
100.00% |
12 / 12 |
|
11.11% |
2 / 18 |
|
100.00% |
1 / 1 |
22.56 | |||
| showCommands | |
100.00% |
13 / 13 |
|
100.00% |
16 / 16 |
|
0.00% |
0 / 36 |
|
100.00% |
1 / 1 |
7 | |||
| run | |
100.00% |
32 / 32 |
|
100.00% |
20 / 20 |
|
75.00% |
9 / 12 |
|
100.00% |
1 / 1 |
11.56 | |||
| normalizeOptions | |
100.00% |
20 / 20 |
|
100.00% |
22 / 22 |
|
7.81% |
5 / 64 |
|
100.00% |
1 / 1 |
72.46 | |||
| runCommand | |
100.00% |
12 / 12 |
|
100.00% |
9 / 9 |
|
55.56% |
5 / 9 |
|
100.00% |
1 / 1 |
7.19 | |||
| bind | |
100.00% |
19 / 19 |
|
100.00% |
20 / 20 |
|
10.61% |
7 / 66 |
|
100.00% |
1 / 1 |
66.86 | |||
| validate | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| validateOptions | |
100.00% |
21 / 21 |
|
100.00% |
32 / 32 |
|
2.10% |
5 / 238 |
|
100.00% |
1 / 1 |
226.12 | |||
| validateArguments | |
100.00% |
27 / 27 |
|
100.00% |
22 / 22 |
|
22.22% |
8 / 36 |
|
100.00% |
1 / 1 |
67.93 | |||
| unknownOption | |
100.00% |
12 / 12 |
|
100.00% |
13 / 13 |
|
11.11% |
2 / 18 |
|
100.00% |
1 / 1 |
31.28 | |||
| showCommandHelp | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| echoGroup | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| echoCommand | |
100.00% |
3 / 3 |
|
85.71% |
6 / 7 |
|
50.00% |
2 / 4 |
|
100.00% |
1 / 1 |
2.50 | |||
| showAmbiguousMessage | |
100.00% |
6 / 6 |
|
100.00% |
4 / 4 |
|
33.33% |
1 / 3 |
|
100.00% |
1 / 1 |
3.19 | |||
| getCommand | |
100.00% |
12 / 12 |
|
92.86% |
13 / 14 |
|
50.00% |
6 / 12 |
|
100.00% |
1 / 1 |
10.50 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Celema\Console; |
| 6 | |
| 7 | use Closure; |
| 8 | use ReflectionFunction; |
| 9 | use ReflectionNamedType; |
| 10 | use Throwable; |
| 11 | use ValueError; |
| 12 | |
| 13 | /** |
| 14 | * @api |
| 15 | */ |
| 16 | final class Runner |
| 17 | { |
| 18 | private const AMBIGUOUS = 1; |
| 19 | |
| 20 | /** |
| 21 | * The commands ordered by group and name. |
| 22 | * |
| 23 | * @var array<string, array{title: string, commands: array<string, Entry>}> |
| 24 | */ |
| 25 | private array $toc = []; |
| 26 | |
| 27 | /** |
| 28 | * The commands indexed by name only. |
| 29 | * |
| 30 | * @var array<string, list<Entry>> |
| 31 | */ |
| 32 | private array $list = []; |
| 33 | private Io $io; |
| 34 | private int $longestName = 0; |
| 35 | |
| 36 | /** |
| 37 | * An Io instance given as `$output` is used as is; `$errorOutput` |
| 38 | * then has no effect. |
| 39 | */ |
| 40 | public function __construct( |
| 41 | Commands $commands, |
| 42 | string|Io $output = 'php://stdout', |
| 43 | string $errorOutput = 'php://stderr', |
| 44 | private bool $debug = false, |
| 45 | ) { |
| 46 | $this->io = is_string($output) ? new Io($output, $errorOutput) : $output; |
| 47 | $this->orderCommands($commands); |
| 48 | } |
| 49 | |
| 50 | private function orderCommands(Commands $commands): void |
| 51 | { |
| 52 | $groups = []; |
| 53 | |
| 54 | foreach ($commands->entries() as $entry) { |
| 55 | $meta = $entry->meta; |
| 56 | |
| 57 | if ($meta->prefix === '' && ($meta->name === 'help' || $meta->name === 'commands')) { |
| 58 | throw new ValueError("Command name '{$meta->name}' is reserved"); |
| 59 | } |
| 60 | |
| 61 | if (!array_key_exists($meta->prefix, $groups)) { |
| 62 | $groups[$meta->prefix] = [ |
| 63 | 'title' => $meta->title(), |
| 64 | 'commands' => [], |
| 65 | ]; |
| 66 | } |
| 67 | |
| 68 | if (array_key_exists($meta->name, $groups[$meta->prefix]['commands'])) { |
| 69 | throw new ValueError("Duplicate command '{$meta->full()}'"); |
| 70 | } |
| 71 | |
| 72 | $groups[$meta->prefix]['commands'][$meta->name] = $entry; |
| 73 | $this->list[$meta->name][] = $entry; |
| 74 | |
| 75 | $len = strlen($meta->full()); |
| 76 | $this->longestName = $len > $this->longestName ? $len : $this->longestName; |
| 77 | } |
| 78 | |
| 79 | $this->longestName = max($this->longestName, strlen('commands')); |
| 80 | |
| 81 | ksort($groups); |
| 82 | |
| 83 | foreach ($groups as $name => $group) { |
| 84 | $commands = $group['commands']; |
| 85 | ksort($commands); |
| 86 | $group['commands'] = $commands; |
| 87 | $this->toc[$name] = $group; |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | public function showHelp(): int |
| 92 | { |
| 93 | $script = $_SERVER['argv'][0] ?? ''; |
| 94 | $this->io->echo("<yellow>Usage:</yellow>\n"); |
| 95 | $this->io->echo(" php {$script} [prefix:]command [arguments]\n\n"); |
| 96 | $this->io->echo("Prefixes are optional if the command is unambiguous.\n\n"); |
| 97 | $this->io->echo("Available commands:\n"); |
| 98 | $this->echoGroup('General'); |
| 99 | $this->echoCommand('', 'commands', 'Lists all available commands'); |
| 100 | $this->echoCommand('', 'help', 'Displays this overview'); |
| 101 | |
| 102 | foreach ($this->toc['']['commands'] ?? [] as $name => $entry) { |
| 103 | $this->echoCommand('', $name, $entry->meta->description); |
| 104 | } |
| 105 | |
| 106 | foreach ($this->toc as $prefix => $group) { |
| 107 | if ($prefix === '') { |
| 108 | continue; |
| 109 | } |
| 110 | |
| 111 | $this->echoGroup($group['title']); |
| 112 | |
| 113 | foreach ($group['commands'] as $name => $entry) { |
| 114 | $this->echoCommand($prefix, $name, $entry->meta->description); |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | return 0; |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * Displays a list of all available commands. |
| 123 | * |
| 124 | * With and without namespace/group. If a bare name is shared, e. g. |
| 125 | * by foo:cmd and bar:cmd, only the namespaced forms are displayed — |
| 126 | * unless the bare name belongs to an unprefixed command, which always |
| 127 | * shows since it resolves exactly. |
| 128 | */ |
| 129 | public function showCommands(): int |
| 130 | { |
| 131 | $list = []; |
| 132 | |
| 133 | foreach ($this->toc as $group) { |
| 134 | foreach ($group['commands'] as $entry) { |
| 135 | $meta = $entry->meta; |
| 136 | |
| 137 | if ($meta->prefix !== '') { |
| 138 | $key = $meta->full(); |
| 139 | $list[$key] = ($list[$key] ?? 0) + 1; |
| 140 | } |
| 141 | |
| 142 | $list[$meta->name] = ($list[$meta->name] ?? 0) + 1; |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | ksort($list); |
| 147 | |
| 148 | foreach ($list as $name => $count) { |
| 149 | if ($count === 1 || array_key_exists($name, $this->toc['']['commands'] ?? [])) { |
| 150 | $this->io->echo("{$name}\n"); |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | return 0; |
| 155 | } |
| 156 | |
| 157 | public function run(): int |
| 158 | { |
| 159 | try { |
| 160 | $argv = $_SERVER['argv'] ?? []; |
| 161 | $arg = $argv[1] ?? null; |
| 162 | |
| 163 | if ($arg === null) { |
| 164 | return $this->showHelp(); |
| 165 | } |
| 166 | |
| 167 | $cmd = strtolower($arg); |
| 168 | $isHelpCall = false; |
| 169 | |
| 170 | if ($cmd === 'help') { |
| 171 | $isHelpCall = true; |
| 172 | $arg = $argv[2] ?? null; |
| 173 | |
| 174 | if ($arg === null) { |
| 175 | return $this->showHelp(); |
| 176 | } |
| 177 | |
| 178 | $cmd = strtolower($arg); |
| 179 | } |
| 180 | |
| 181 | if ($cmd === 'commands') { |
| 182 | return $this->showCommands(); |
| 183 | } |
| 184 | |
| 185 | $tokens = array_slice($argv, offset: 2); |
| 186 | |
| 187 | try { |
| 188 | $entry = $this->getCommand($cmd); |
| 189 | } catch (ValueError $e) { |
| 190 | if ($e->getCode() === self::AMBIGUOUS) { |
| 191 | return $this->showAmbiguousMessage($cmd); |
| 192 | } |
| 193 | |
| 194 | throw $e; |
| 195 | } |
| 196 | |
| 197 | if ($isHelpCall) { |
| 198 | return $this->showCommandHelp($entry); |
| 199 | } |
| 200 | |
| 201 | $args = new Args($this->normalizeOptions($entry, $tokens)); |
| 202 | |
| 203 | return $this->runCommand($entry, $args); |
| 204 | } catch (Throwable $e) { |
| 205 | // Escape the arbitrary strings: a message containing markup |
| 206 | // (or broken markup) must never throw while reporting. `$arg` |
| 207 | // names the effective target, e.g. `x` for `help x`. |
| 208 | $this->io->echoErr("Error while running command '"); |
| 209 | $this->io->echoErr($this->io->escape($arg ?? '<no command given>')); |
| 210 | $this->io->echoErr("':\n\n" . $this->io->escape($e->getMessage()) . "\n"); |
| 211 | |
| 212 | if ($this->debug) { |
| 213 | $this->io->echolnErr("\n<yellow>Traceback:</yellow>"); |
| 214 | $this->io->echolnErr($this->io->escape($e->getTraceAsString())); |
| 215 | } |
| 216 | |
| 217 | return 1; |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | /** |
| 222 | * @param list<string> $tokens |
| 223 | * @return list<string> |
| 224 | */ |
| 225 | private function normalizeOptions(Entry $entry, array $tokens): array |
| 226 | { |
| 227 | $aliases = []; |
| 228 | |
| 229 | foreach ($entry->opts() as $opt) { |
| 230 | if ($opt->short !== '') { |
| 231 | $aliases[$opt->short] = $opt->long; |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | $normalized = []; |
| 236 | $literal = false; |
| 237 | |
| 238 | foreach ($tokens as $token) { |
| 239 | // Aliasing stops at the `--` separator; Args reads every |
| 240 | // later token as a positional. The token is command-line |
| 241 | // input, not a secret. |
| 242 | // @mago-expect lint:no-insecure-comparison |
| 243 | if ($literal || $token === '--') { |
| 244 | $literal = true; |
| 245 | $normalized[] = $token; |
| 246 | |
| 247 | continue; |
| 248 | } |
| 249 | |
| 250 | $separator = strpos(haystack: $token, needle: '='); |
| 251 | $name = $separator === false |
| 252 | ? $token |
| 253 | : substr(string: $token, offset: 0, length: $separator); |
| 254 | $long = $aliases[$name] ?? null; |
| 255 | $normalized[] = $long === null |
| 256 | ? $token |
| 257 | : $long . ($separator === false ? '' : substr(string: $token, offset: $separator)); |
| 258 | } |
| 259 | |
| 260 | return $normalized; |
| 261 | } |
| 262 | |
| 263 | private function runCommand(Entry $entry, Args $args): int |
| 264 | { |
| 265 | $this->validate($entry, $args); |
| 266 | $command = $entry->command(); |
| 267 | $full = $entry->meta->full(); |
| 268 | |
| 269 | if (!is_callable($command)) { |
| 270 | throw new ValueError("Command '{$full}' is not callable"); |
| 271 | } |
| 272 | |
| 273 | $function = new ReflectionFunction(Closure::fromCallable($command)); |
| 274 | $return = $function->getReturnType(); |
| 275 | |
| 276 | if ( |
| 277 | !$return instanceof ReflectionNamedType |
| 278 | || $return->getName() !== 'int' |
| 279 | || $return->allowsNull() |
| 280 | ) { |
| 281 | throw new ValueError("Command '{$full}' must declare the return type int"); |
| 282 | } |
| 283 | |
| 284 | /** @var int Guaranteed by the declared return type under strict_types */ |
| 285 | return $command(...$this->bind($function, $full, $args)); |
| 286 | } |
| 287 | |
| 288 | /** |
| 289 | * Builds the argument list from the command's parameters. |
| 290 | * |
| 291 | * Commands may declare any subset of Args and Io in any order; |
| 292 | * arguments are matched by declared type. Other parameters are |
| 293 | * rejected. |
| 294 | * |
| 295 | * @return list<Args|Io> |
| 296 | */ |
| 297 | private function bind(ReflectionFunction $function, string $full, Args $args): array |
| 298 | { |
| 299 | $available = [Args::class => $args, Io::class => $this->io]; |
| 300 | $bound = []; |
| 301 | |
| 302 | foreach ($function->getParameters() as $parameter) { |
| 303 | $type = $parameter->getType(); |
| 304 | $class = $type instanceof ReflectionNamedType |
| 305 | && !$type->allowsNull() |
| 306 | && !$parameter->isVariadic() |
| 307 | ? $type->getName() |
| 308 | : ''; |
| 309 | |
| 310 | if ($class !== Args::class && $class !== Io::class) { |
| 311 | throw new ValueError( |
| 312 | "Command '{$full}' parameter \${$parameter->getName()} must be declared as Args or Io", |
| 313 | ); |
| 314 | } |
| 315 | |
| 316 | if (!array_key_exists($class, $available)) { |
| 317 | $short = $class === Args::class ? 'Args' : 'Io'; |
| 318 | |
| 319 | throw new ValueError("Command '{$full}' declares more than one {$short} parameter"); |
| 320 | } |
| 321 | |
| 322 | $bound[] = $available[$class]; |
| 323 | unset($available[$class]); |
| 324 | } |
| 325 | |
| 326 | return $bound; |
| 327 | } |
| 328 | |
| 329 | /** |
| 330 | * Checks the provided options against the command's declared `#[Opt]`s |
| 331 | * and the positionals against its `#[Arg]`s. |
| 332 | * |
| 333 | * The declarations are the command's complete interface: undeclared |
| 334 | * options and positionals are rejected. Declare a variadic `#[Arg]` |
| 335 | * for open-ended input. |
| 336 | */ |
| 337 | private function validate(Entry $entry, Args $args): void |
| 338 | { |
| 339 | $this->validateOptions($entry, $args); |
| 340 | $this->validateArguments($entry, $args); |
| 341 | } |
| 342 | |
| 343 | private function validateOptions(Entry $entry, Args $args): void |
| 344 | { |
| 345 | $opts = $entry->opts(); |
| 346 | $declared = []; |
| 347 | |
| 348 | foreach ($opts as $opt) { |
| 349 | if ( |
| 350 | array_key_exists($opt->long, $declared) |
| 351 | || $opt->short !== '' && array_key_exists($opt->short, $declared) |
| 352 | ) { |
| 353 | $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short; |
| 354 | |
| 355 | throw new ValueError( |
| 356 | "Command '{$entry->meta->full()}' declares the option name '{$name}' twice", |
| 357 | ); |
| 358 | } |
| 359 | |
| 360 | $declared[$opt->long] = $opt; |
| 361 | |
| 362 | if ($opt->short !== '') { |
| 363 | $declared[$opt->short] = $opt; |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | foreach ($args->names() as $name) { |
| 368 | $opt = $declared[$name] ?? null; |
| 369 | |
| 370 | if ($opt === null) { |
| 371 | throw new ValueError($this->unknownOption($name, $entry->meta->full(), array_keys($declared))); |
| 372 | } |
| 373 | |
| 374 | $values = $args->opts($name); |
| 375 | |
| 376 | if ($opt->value === '' && $values !== []) { |
| 377 | throw new ValueError("Option '{$name}' does not accept a value"); |
| 378 | } |
| 379 | |
| 380 | // Every occurrence needs a value, also when a repetition |
| 381 | // provides one: `--host --host=x` hides a bare `--host`. |
| 382 | if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) { |
| 383 | throw new ValueError("Option '{$name}' requires a value: {$name}=<{$opt->value}>"); |
| 384 | } |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | private function validateArguments(Entry $entry, Args $args): void |
| 389 | { |
| 390 | $declared = $entry->args(); |
| 391 | $positionals = $args->positionals(); |
| 392 | |
| 393 | if ($declared === []) { |
| 394 | if ($positionals !== []) { |
| 395 | throw new ValueError("Unexpected argument '{$positionals[0]}'"); |
| 396 | } |
| 397 | |
| 398 | return; |
| 399 | } |
| 400 | |
| 401 | $last = count($declared) - 1; |
| 402 | $required = 0; |
| 403 | |
| 404 | foreach ($declared as $index => $arg) { |
| 405 | if ($arg->variadic && $index < $last) { |
| 406 | throw new ValueError( |
| 407 | "Command '{$entry->meta->full()}' declares an argument " |
| 408 | . "after the variadic '<{$arg->name}>'", |
| 409 | ); |
| 410 | } |
| 411 | |
| 412 | if ($arg->optional) { |
| 413 | continue; |
| 414 | } |
| 415 | |
| 416 | // Required arguments must form a prefix of the declaration. |
| 417 | if ($index > $required) { |
| 418 | throw new ValueError( |
| 419 | "Command '{$entry->meta->full()}' declares the required argument " |
| 420 | . "'<{$arg->name}>' after an optional one", |
| 421 | ); |
| 422 | } |
| 423 | |
| 424 | $required++; |
| 425 | } |
| 426 | |
| 427 | $count = count($positionals); |
| 428 | |
| 429 | if ($count < $required) { |
| 430 | throw new ValueError("Missing required argument '<{$declared[$count]->name}>'"); |
| 431 | } |
| 432 | |
| 433 | // A variadic last argument accepts the remaining positionals. |
| 434 | if (!$declared[$last]->variadic && $count > count($declared)) { |
| 435 | throw new ValueError("Unexpected argument '{$positionals[count($declared)]}'"); |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | /** @param list<string> $declared */ |
| 440 | private function unknownOption(string $name, string $full, array $declared): string |
| 441 | { |
| 442 | if ($name === '--help' || $name === '-h') { |
| 443 | $script = $_SERVER['argv'][0] ?? 'run'; |
| 444 | |
| 445 | return "Unknown option '{$name}'. Use 'php {$script} help {$full}' to show the command's help"; |
| 446 | } |
| 447 | |
| 448 | $message = "Unknown option '{$name}'"; |
| 449 | $best = ''; |
| 450 | $bestDistance = PHP_INT_MAX; |
| 451 | |
| 452 | foreach ($declared as $candidate) { |
| 453 | $distance = levenshtein($name, $candidate); |
| 454 | |
| 455 | if ($distance < $bestDistance) { |
| 456 | $bestDistance = $distance; |
| 457 | $best = $candidate; |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message; |
| 462 | } |
| 463 | |
| 464 | private function showCommandHelp(Entry $entry): int |
| 465 | { |
| 466 | new Help($this->io)->show($entry->meta, $entry->opts(), $entry->args()); |
| 467 | |
| 468 | return 0; |
| 469 | } |
| 470 | |
| 471 | private function echoGroup(string $title): void |
| 472 | { |
| 473 | $this->io->echo("\n<yellow>{$title}</yellow>\n"); |
| 474 | } |
| 475 | |
| 476 | private function echoCommand(string $prefix, string $name, string $desc): void |
| 477 | { |
| 478 | $prefix = $prefix ? $prefix . ':' : ''; |
| 479 | |
| 480 | // Pad on the visible length; the markup tags don't print. |
| 481 | $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name))); |
| 482 | $this->io->echoln(" {$prefix}<green>{$name}</green>{$pad}{$desc}"); |
| 483 | } |
| 484 | |
| 485 | private function showAmbiguousMessage(string $cmd): int |
| 486 | { |
| 487 | $this->io->echoErr("Ambiguous command. Please add the group name:\n\n"); |
| 488 | $entries = $this->list[$cmd]; |
| 489 | usort($entries, static fn(Entry $a, Entry $b): int => strcmp($a->meta->full(), $b->meta->full())); |
| 490 | |
| 491 | foreach ($entries as $entry) { |
| 492 | $this->io->echolnErr(" <yellow>{$entry->meta->prefix}</yellow>:{$entry->meta->name}"); |
| 493 | } |
| 494 | |
| 495 | return 1; |
| 496 | } |
| 497 | |
| 498 | private function getCommand(string $cmd): Entry |
| 499 | { |
| 500 | // Exact full names resolve first: an unprefixed command wins over |
| 501 | // its prefixed namesakes, and prefixed lookups are always exact. |
| 502 | // Only then a bare name serves as the alias of a unique prefixed |
| 503 | // command; shared by several, it is ambiguous. |
| 504 | if (str_contains($cmd, ':')) { |
| 505 | /** @var array{0: string, 1: string} $parts */ |
| 506 | $parts = explode(':', $cmd, limit: 2); |
| 507 | |
| 508 | if (array_key_exists($parts[1], $this->toc[$parts[0]]['commands'] ?? [])) { |
| 509 | return $this->toc[$parts[0]]['commands'][$parts[1]]; |
| 510 | } |
| 511 | |
| 512 | throw new ValueError('Command not found'); |
| 513 | } |
| 514 | |
| 515 | if (array_key_exists($cmd, $this->toc['']['commands'] ?? [])) { |
| 516 | return $this->toc['']['commands'][$cmd]; |
| 517 | } |
| 518 | |
| 519 | if (array_key_exists($cmd, $this->list)) { |
| 520 | if (count($this->list[$cmd]) === 1) { |
| 521 | return $this->list[$cmd][0]; |
| 522 | } |
| 523 | |
| 524 | throw new ValueError('Ambiguous command', self::AMBIGUOUS); |
| 525 | } |
| 526 | |
| 527 | throw new ValueError('Command not found'); |
| 528 | } |
| 529 | } |
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.
| 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 | } |
| 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 | } |
| 476 | private function echoCommand(string $prefix, string $name, string $desc): void |
| 477 | { |
| 478 | $prefix = $prefix ? $prefix . ':' : ''; |
| 478 | $prefix = $prefix ? $prefix . ':' : ''; |
| 478 | $prefix = $prefix ? $prefix . ':' : ''; |
| 478 | $prefix = $prefix ? $prefix . ':' : ''; |
| 479 | |
| 480 | // Pad on the visible length; the markup tags don't print. |
| 481 | $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name))); |
| 481 | $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name))); |
| 481 | $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name))); |
| 481 | $pad = str_repeat(' ', max(2, $this->longestName + 2 - strlen($prefix . $name))); |
| 482 | $this->io->echoln(" {$prefix}<green>{$name}</green>{$pad}{$desc}"); |
| 483 | } |
| 471 | private function echoGroup(string $title): void |
| 472 | { |
| 473 | $this->io->echo("\n<yellow>{$title}</yellow>\n"); |
| 474 | } |
| 498 | private function getCommand(string $cmd): Entry |
| 499 | { |
| 500 | // Exact full names resolve first: an unprefixed command wins over |
| 501 | // its prefixed namesakes, and prefixed lookups are always exact. |
| 502 | // Only then a bare name serves as the alias of a unique prefixed |
| 503 | // command; shared by several, it is ambiguous. |
| 504 | if (str_contains($cmd, ':')) { |
| 504 | if (str_contains($cmd, ':')) { |
| 504 | if (str_contains($cmd, ':')) { |
| 504 | if (str_contains($cmd, ':')) { |
| 506 | $parts = explode(':', $cmd, limit: 2); |
| 507 | |
| 508 | if (array_key_exists($parts[1], $this->toc[$parts[0]]['commands'] ?? [])) { |
| 509 | return $this->toc[$parts[0]]['commands'][$parts[1]]; |
| 512 | throw new ValueError('Command not found'); |
| 515 | if (array_key_exists($cmd, $this->toc['']['commands'] ?? [])) { |
| 516 | return $this->toc['']['commands'][$cmd]; |
| 519 | if (array_key_exists($cmd, $this->list)) { |
| 520 | if (count($this->list[$cmd]) === 1) { |
| 521 | return $this->list[$cmd][0]; |
| 524 | throw new ValueError('Ambiguous command', self::AMBIGUOUS); |
| 527 | throw new ValueError('Command not found'); |
| 528 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 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 | } |
| 485 | private function showAmbiguousMessage(string $cmd): int |
| 486 | { |
| 487 | $this->io->echoErr("Ambiguous command. Please add the group name:\n\n"); |
| 488 | $entries = $this->list[$cmd]; |
| 489 | usort($entries, static fn(Entry $a, Entry $b): int => strcmp($a->meta->full(), $b->meta->full())); |
| 490 | |
| 491 | foreach ($entries as $entry) { |
| 491 | foreach ($entries as $entry) { |
| 491 | foreach ($entries as $entry) { |
| 492 | $this->io->echolnErr(" <yellow>{$entry->meta->prefix}</yellow>:{$entry->meta->name}"); |
| 491 | foreach ($entries as $entry) { |
| 492 | $this->io->echolnErr(" <yellow>{$entry->meta->prefix}</yellow>:{$entry->meta->name}"); |
| 493 | } |
| 494 | |
| 495 | return 1; |
| 496 | } |
| 464 | private function showCommandHelp(Entry $entry): int |
| 465 | { |
| 466 | new Help($this->io)->show($entry->meta, $entry->opts(), $entry->args()); |
| 467 | |
| 468 | return 0; |
| 469 | } |
| 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 | } |
| 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 | } |
| 440 | private function unknownOption(string $name, string $full, array $declared): string |
| 441 | { |
| 442 | if ($name === '--help' || $name === '-h') { |
| 442 | if ($name === '--help' || $name === '-h') { |
| 442 | if ($name === '--help' || $name === '-h') { |
| 443 | $script = $_SERVER['argv'][0] ?? 'run'; |
| 444 | |
| 445 | return "Unknown option '{$name}'. Use 'php {$script} help {$full}' to show the command's help"; |
| 448 | $message = "Unknown option '{$name}'"; |
| 449 | $best = ''; |
| 450 | $bestDistance = PHP_INT_MAX; |
| 451 | |
| 452 | foreach ($declared as $candidate) { |
| 452 | foreach ($declared as $candidate) { |
| 453 | $distance = levenshtein($name, $candidate); |
| 454 | |
| 455 | if ($distance < $bestDistance) { |
| 452 | foreach ($declared as $candidate) { |
| 453 | $distance = levenshtein($name, $candidate); |
| 454 | |
| 455 | if ($distance < $bestDistance) { |
| 456 | $bestDistance = $distance; |
| 452 | foreach ($declared as $candidate) { |
| 452 | foreach ($declared as $candidate) { |
| 453 | $distance = levenshtein($name, $candidate); |
| 454 | |
| 455 | if ($distance < $bestDistance) { |
| 456 | $bestDistance = $distance; |
| 457 | $best = $candidate; |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message; |
| 461 | return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message; |
| 461 | return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message; |
| 461 | return $bestDistance <= 3 ? "{$message}. Did you mean '{$best}'?" : $message; |
| 462 | } |
| 337 | private function validate(Entry $entry, Args $args): void |
| 338 | { |
| 339 | $this->validateOptions($entry, $args); |
| 340 | $this->validateArguments($entry, $args); |
| 341 | } |
| 388 | private function validateArguments(Entry $entry, Args $args): void |
| 389 | { |
| 390 | $declared = $entry->args(); |
| 391 | $positionals = $args->positionals(); |
| 392 | |
| 393 | if ($declared === []) { |
| 394 | if ($positionals !== []) { |
| 395 | throw new ValueError("Unexpected argument '{$positionals[0]}'"); |
| 398 | return; |
| 401 | $last = count($declared) - 1; |
| 402 | $required = 0; |
| 403 | |
| 404 | foreach ($declared as $index => $arg) { |
| 404 | foreach ($declared as $index => $arg) { |
| 404 | foreach ($declared as $index => $arg) { |
| 405 | if ($arg->variadic && $index < $last) { |
| 405 | if ($arg->variadic && $index < $last) { |
| 405 | if ($arg->variadic && $index < $last) { |
| 406 | throw new ValueError( |
| 407 | "Command '{$entry->meta->full()}' declares an argument " |
| 408 | . "after the variadic '<{$arg->name}>'", |
| 412 | if ($arg->optional) { |
| 413 | continue; |
| 417 | if ($index > $required) { |
| 418 | throw new ValueError( |
| 419 | "Command '{$entry->meta->full()}' declares the required argument " |
| 420 | . "'<{$arg->name}>' after an optional one", |
| 404 | foreach ($declared as $index => $arg) { |
| 405 | if ($arg->variadic && $index < $last) { |
| 406 | throw new ValueError( |
| 407 | "Command '{$entry->meta->full()}' declares an argument " |
| 408 | . "after the variadic '<{$arg->name}>'", |
| 409 | ); |
| 410 | } |
| 411 | |
| 412 | if ($arg->optional) { |
| 413 | continue; |
| 414 | } |
| 415 | |
| 416 | // Required arguments must form a prefix of the declaration. |
| 417 | if ($index > $required) { |
| 418 | throw new ValueError( |
| 419 | "Command '{$entry->meta->full()}' declares the required argument " |
| 420 | . "'<{$arg->name}>' after an optional one", |
| 421 | ); |
| 422 | } |
| 423 | |
| 424 | $required++; |
| 404 | foreach ($declared as $index => $arg) { |
| 405 | if ($arg->variadic && $index < $last) { |
| 406 | throw new ValueError( |
| 407 | "Command '{$entry->meta->full()}' declares an argument " |
| 408 | . "after the variadic '<{$arg->name}>'", |
| 409 | ); |
| 410 | } |
| 411 | |
| 412 | if ($arg->optional) { |
| 413 | continue; |
| 414 | } |
| 415 | |
| 416 | // Required arguments must form a prefix of the declaration. |
| 417 | if ($index > $required) { |
| 418 | throw new ValueError( |
| 419 | "Command '{$entry->meta->full()}' declares the required argument " |
| 420 | . "'<{$arg->name}>' after an optional one", |
| 421 | ); |
| 422 | } |
| 423 | |
| 424 | $required++; |
| 425 | } |
| 426 | |
| 427 | $count = count($positionals); |
| 428 | |
| 429 | if ($count < $required) { |
| 430 | throw new ValueError("Missing required argument '<{$declared[$count]->name}>'"); |
| 434 | if (!$declared[$last]->variadic && $count > count($declared)) { |
| 434 | if (!$declared[$last]->variadic && $count > count($declared)) { |
| 434 | if (!$declared[$last]->variadic && $count > count($declared)) { |
| 435 | throw new ValueError("Unexpected argument '{$positionals[count($declared)]}'"); |
| 437 | } |
| 343 | private function validateOptions(Entry $entry, Args $args): void |
| 344 | { |
| 345 | $opts = $entry->opts(); |
| 346 | $declared = []; |
| 347 | |
| 348 | foreach ($opts as $opt) { |
| 348 | foreach ($opts as $opt) { |
| 350 | array_key_exists($opt->long, $declared) |
| 351 | || $opt->short !== '' && array_key_exists($opt->short, $declared) |
| 351 | || $opt->short !== '' && array_key_exists($opt->short, $declared) |
| 351 | || $opt->short !== '' && array_key_exists($opt->short, $declared) |
| 351 | || $opt->short !== '' && array_key_exists($opt->short, $declared) |
| 353 | $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short; |
| 353 | $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short; |
| 353 | $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short; |
| 353 | $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short; |
| 354 | |
| 355 | throw new ValueError( |
| 356 | "Command '{$entry->meta->full()}' declares the option name '{$name}' twice", |
| 360 | $declared[$opt->long] = $opt; |
| 361 | |
| 362 | if ($opt->short !== '') { |
| 348 | foreach ($opts as $opt) { |
| 349 | if ( |
| 350 | array_key_exists($opt->long, $declared) |
| 351 | || $opt->short !== '' && array_key_exists($opt->short, $declared) |
| 352 | ) { |
| 353 | $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short; |
| 354 | |
| 355 | throw new ValueError( |
| 356 | "Command '{$entry->meta->full()}' declares the option name '{$name}' twice", |
| 357 | ); |
| 358 | } |
| 359 | |
| 360 | $declared[$opt->long] = $opt; |
| 361 | |
| 362 | if ($opt->short !== '') { |
| 363 | $declared[$opt->short] = $opt; |
| 348 | foreach ($opts as $opt) { |
| 348 | foreach ($opts as $opt) { |
| 349 | if ( |
| 350 | array_key_exists($opt->long, $declared) |
| 351 | || $opt->short !== '' && array_key_exists($opt->short, $declared) |
| 352 | ) { |
| 353 | $name = array_key_exists($opt->long, $declared) ? $opt->long : $opt->short; |
| 354 | |
| 355 | throw new ValueError( |
| 356 | "Command '{$entry->meta->full()}' declares the option name '{$name}' twice", |
| 357 | ); |
| 358 | } |
| 359 | |
| 360 | $declared[$opt->long] = $opt; |
| 361 | |
| 362 | if ($opt->short !== '') { |
| 363 | $declared[$opt->short] = $opt; |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | foreach ($args->names() as $name) { |
| 367 | foreach ($args->names() as $name) { |
| 368 | $opt = $declared[$name] ?? null; |
| 369 | |
| 370 | if ($opt === null) { |
| 371 | throw new ValueError($this->unknownOption($name, $entry->meta->full(), array_keys($declared))); |
| 374 | $values = $args->opts($name); |
| 375 | |
| 376 | if ($opt->value === '' && $values !== []) { |
| 376 | if ($opt->value === '' && $values !== []) { |
| 376 | if ($opt->value === '' && $values !== []) { |
| 377 | throw new ValueError("Option '{$name}' does not accept a value"); |
| 382 | if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) { |
| 382 | if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) { |
| 382 | if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) { |
| 382 | if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) { |
| 382 | if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) { |
| 382 | if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) { |
| 382 | if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) { |
| 383 | throw new ValueError("Option '{$name}' requires a value: {$name}=<{$opt->value}>"); |
| 367 | foreach ($args->names() as $name) { |
| 367 | foreach ($args->names() as $name) { |
| 368 | $opt = $declared[$name] ?? null; |
| 369 | |
| 370 | if ($opt === null) { |
| 371 | throw new ValueError($this->unknownOption($name, $entry->meta->full(), array_keys($declared))); |
| 372 | } |
| 373 | |
| 374 | $values = $args->opts($name); |
| 375 | |
| 376 | if ($opt->value === '' && $values !== []) { |
| 377 | throw new ValueError("Option '{$name}' does not accept a value"); |
| 378 | } |
| 379 | |
| 380 | // Every occurrence needs a value, also when a repetition |
| 381 | // provides one: `--host --host=x` hides a bare `--host`. |
| 382 | if ($opt->value !== '' && !$opt->optionalValue && ($values === [] || $args->bare($name))) { |
| 383 | throw new ValueError("Option '{$name}' requires a value: {$name}=<{$opt->value}>"); |
| 384 | } |
| 385 | } |
| 386 | } |
| 489 | usort($entries, static fn(Entry $a, Entry $b): int => strcmp($a->meta->full(), $b->meta->full())); |