Issues (14)

src/Cli.php (2 issues)

1
<?php
2
3
namespace Garden\Cli;
4
5
/**
6
 * A general purpose command line parser.
7
 *
8
 * @author Todd Burry <[email protected]>
9
 * @license MIT
10
 * @copyright 2010-2014 Vanilla Forums Inc.
11
 */
12
class Cli {
13
    /// Constants ///
14
15
    const META = '__meta';
16
    const ARGS = '__args';
17
18
    /// Properties ///
19
    /**
20
     * @var array All of the schemas, indexed by command pattern.
21
     */
22
    protected $commandSchemas;
23
24
    /**
25
     * @var array A pointer to the current schema.
26
     */
27
    protected $currentSchema;
28
29
    /**
30
     * @var bool Whether or not to format output with console codes.
31
     */
32
    protected $formatOutput;
33
34
    protected static $types = [
35
//        '=' => 'base64',
36
        'i' => 'integer',
37
        's' => 'string',
38
//        'f' => 'float',
39
        'b' => 'boolean',
40
//        'ts' => 'timestamp',
41
//        'dt' => 'datetime'
42
    ];
43
44
45
    /// Methods ///
46
47
    /**
48
     * Creates a {@link Cli} instance representing a command line parser for a given schema.
49
     */
50 41
    public function __construct() {
51 41
        $this->commandSchemas = ['*' => [Cli::META => []]];
52
53
        // Select the current schema.
54 41
        $this->currentSchema =& $this->commandSchemas['*'];
55
56 41
        $this->formatOutput = static::guessFormatOutput();
57 41
    }
58
59
    /**
60
     * Backwards compatibility for the **format** property.
61
     *
62
     * @param string $name Must be **format**.
63
     * @return bool|null Returns {@link getFormatOutput()} or null if {@link $name} isn't **format**.
64
     */
65 1
    public function __get($name) {
66 1
        if ($name === 'format') {
67 1
            trigger_error("Cli->format is deprecated. Use Cli->getFormatOutput() instead.", E_USER_DEPRECATED);
68 1
            return $this->getFormatOutput();
69
        }
70
        return null;
71
    }
72
73
    /**
74
     * Backwards compatibility for the **format** property.
75
     *
76
     * @param string $name Must be **format**.
77
     * @param bool $value One of **true** or **false**.
78
     */
79 1
    public function __set($name, $value) {
80 1
        if ($name === 'format') {
81 1
            trigger_error("Cli->format is deprecated. Use Cli->setFormatOutput() instead.", E_USER_DEPRECATED);
82 1
            $this->setFormatOutput($value);
83
        }
84 1
    }
85
86
    /**
87
     * Get whether or not output should be formatted.
88
     *
89
     * @return boolean Returns **true** if output should be formatted or **false** otherwise.
90
     */
91 1
    public function getFormatOutput() {
92 1
        return $this->formatOutput;
93
    }
94
95
    /**
96
     * Set whether or not output should be formatted.
97
     *
98
     * @param boolean $formatOutput Whether or not to format output.
99
     * @return $this
100
     */
101 2
    public function setFormatOutput($formatOutput) {
102 2
        $this->formatOutput = $formatOutput;
103 2
        return $this;
104
    }
105
106
    /**
107
     * Add an argument to an {@link Args} object, checking for a correct name.
108
     *
109
     * @param array $schema The schema for the args.
110
     * @param Args $args The args object to add the argument to.
111
     * @param mixed $arg The value of the argument.
112
     */
113 2
    private function addArg(array $schema, Args $args, $arg) {
114 2
        $argsCount = count($args->getArgs());
115 2
        $schemaArgs = isset($schema[self::META][self::ARGS]) ? array_keys($schema[self::META][self::ARGS]) : [];
116 2
        $name = isset($schemaArgs[$argsCount]) ? $schemaArgs[$argsCount] : $argsCount;
117
118 2
        $args->addArg($arg, $name);
119 2
    }
120
121
    /**
122
     * Construct and return a new {@link Cli} object.
123
     *
124
     * This method is mainly here so that an entire cli schema can be created and defined with fluent method calls.
125
     *
126
     * @param array $args The constructor arguments, if any.
127
     * @return static Returns a new Cli object.
128
     */
129 2
    public static function create(...$args) {
130 2
        return new static(...$args);
0 ignored issues
show
The call to Garden\Cli\Cli::__construct() has too many arguments starting with $args. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

130
        return /** @scrutinizer ignore-call */ new static(...$args);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
131
    }
132
133
    /**
134
     * Breaks a cell into several lines according to a given width.
135
     *
136
     * @param string $text The text of the cell.
137
     * @param int $width The width of the cell.
138
     * @param bool $addSpaces Whether or not to right-pad the cell with spaces.
139
     * @return array Returns an array of strings representing the lines in the cell.
140
     */
141 7
    public static function breakLines($text, $width, $addSpaces = true) {
142 7
        $rawLines = explode("\n", $text);
143 7
        $lines = [];
144
145 7
        foreach ($rawLines as $line) {
146
            // Check to see if the line needs to be broken.
147 7
            $sublines = static::breakString($line, $width, $addSpaces);
148 7
            $lines = array_merge($lines, $sublines);
149
        }
150
151 7
        return $lines;
152
    }
153
154
    /**
155
     * Breaks a line of text according to a given width.
156
     *
157
     * @param string $line The text of the line.
158
     * @param int $width The width of the cell.
159
     * @param bool $addSpaces Whether or not to right pad the lines with spaces.
160
     * @return array Returns an array of lines, broken on word boundaries.
161
     */
162 7
    protected static function breakString($line, $width, $addSpaces = true) {
163 7
        $words = explode(' ', $line);
164 7
        $result = [];
165
166 7
        $line = '';
167 7
        foreach ($words as $word) {
168 7
            $candidate = trim($line.' '.$word);
169
170
            // Check for a new line.
171 7
            if (strlen($candidate) > $width) {
172 2
                if ($line === '') {
173
                    // The word is longer than a line.
174
                    if ($addSpaces) {
175
                        $result[] = substr($candidate, 0, $width);
176
                    } else {
177
                        $result[] = $candidate;
178
                    }
179
                } else {
180 2
                    if ($addSpaces) {
181
                        $line .= str_repeat(' ', $width - strlen($line));
182
                    }
183
184
                    // Start a new line.
185 2
                    $result[] = $line;
186 2
                    $line = $word;
187
                }
188
            } else {
189 7
                $line = $candidate;
190
            }
191
        }
192
193
        // Add the remaining line.
194 7
        if ($line) {
195 7
            if ($addSpaces) {
196 7
                $line .= str_repeat(' ', $width - strlen($line));
197
            }
198
199
            // Start a new line.
200 7
            $result[] = $line;
201
        }
202
203 7
        return $result;
204
    }
205
206
    /**
207
     * Sets the description for the current command.
208
     *
209
     * @param string $str The description for the current schema or null to get the current description.
210
     * @return $this
211
     */
212 8
    public function description($str = null) {
213 8
        return $this->meta('description', $str);
214
    }
215
216
    /**
217
     * Determines whether or not the schema has a command.
218
     *
219
     * @param string $name Check for the specific command name.
220
     * @return bool Returns true if the schema has a command.
221
     */
222 39
    public function hasCommand($name = '') {
223 39
        if ($name) {
224
            return array_key_exists($name, $this->commandSchemas);
225
        } else {
226 39
            foreach ($this->commandSchemas as $pattern => $opts) {
227 39
                if (strpos($pattern, '*') === false) {
228 39
                    return true;
229
                }
230
            }
231 35
            return false;
232
        }
233
    }
234
235
    /**
236
     * Determines whether a command has options.
237
     *
238
     * @param string $command The name of the command or an empty string for any command.
239
     * @return bool Returns true if the command has options. False otherwise.
240
     */
241 6
    public function hasOptions($command = '') {
242 6
        if ($command) {
243 2
            $def = $this->getSchema($command);
244 2
            return $this->hasOptionsDef($def);
245
        } else {
246 4
            foreach ($this->commandSchemas as $pattern => $def) {
247 4
                if ($this->hasOptionsDef($def)) {
248 4
                    return true;
249
                }
250
            }
251
        }
252
        return false;
253
    }
254
255
    /**
256
     * Determines whether or not a command definition has options.
257
     *
258
     * @param array $commandDef The command definition as returned from {@link Cli::getSchema()}.
259
     * @return bool Returns true if the command def has options or false otherwise.
260
     */
261 6
    protected function hasOptionsDef($commandDef) {
262 6
        return count($commandDef) > 1 || (count($commandDef) > 0 && !isset($commandDef[Cli::META]));
263
    }
264
265
    /**
266
     * Determines whether or not a command has args.
267
     *
268
     * @param string $command The command name to check.
269
     * @return int Returns one of the following.
270
     * - 0: The command has no args.
271
     * - 1: The command has optional args.
272
     * - 2: The command has required args.
273
     */
274 6
    public function hasArgs($command = '') {
275 6
        $args = null;
276
277 6
        if ($command) {
278
            // Check to see if the specific command has args.
279 2
            $def = $this->getSchema($command);
280 2
            if (isset($def[Cli::META][Cli::ARGS])) {
281 2
                $args = $def[Cli::META][Cli::ARGS];
282
            }
283
        } else {
284 4
            foreach ($this->commandSchemas as $pattern => $def) {
285 4
                if (isset($def[Cli::META][Cli::ARGS])) {
286 4
                    $args = $def[Cli::META][Cli::ARGS];
287
                }
288
            }
289 4
            if (!empty($args)) {
290 2
                return 1;
291
            }
292
        }
293
294 4
        if (!$args || empty($args)) {
295 2
            return 0;
296
        }
297
298 2
        foreach ($args as $arg) {
299 2
            if (!Cli::val('required', $arg)) {
300 2
                return 1;
301
            }
302
        }
303 1
        return 2;
304
    }
305
306
    /**
307
     * Finds our whether a pattern is a command.
308
     *
309
     * @param string $pattern The pattern being evaluated.
310
     * @return bool Returns `true` if `$pattern` is a command, `false` otherwise.
311
     */
312 2
    public static function isCommand($pattern) {
313 2
        return strpos($pattern, '*') === false;
314
    }
315
316
    /**
317
     * Parses and validates a set of command line arguments the schema.
318
     *
319
     * @param array $argv The command line arguments a form compatible with the global `$argv` variable.
320
     *
321
     * Note that the `$argv` array must have at least one element and it must represent the path to the command that
322
     * invoked the command. This is used to write usage information.
323
     * @param bool $exit Whether to exit the application when there is an error or when writing help.
324
     * @return Args|null Returns an {@see Args} instance when a command should be executed
325
     * or `null` when one should not be executed.
326
     * @throws \Exception Throws an exception when {@link $exit} is false and the help or errors need to be displayed.
327
     */
328 39
    public function parse($argv = null, $exit = true) {
329 39
        $formatOutputBak = $this->formatOutput;
330
        // Only format commands if we are exiting.
331 39
        if (!$exit) {
332 38
            $this->formatOutput = false;
333
        }
334 39
        if (!$exit) {
335 38
            ob_start();
336
        }
337
338 39
        $args = $this->parseRaw($argv);
339
340 39
        $hasCommand = $this->hasCommand();
341
342 39
        if ($hasCommand && !$args->getCommand()) {
343
            // If no command is given then write a list of commands.
344 2
            $this->writeUsage($args);
345 2
            $this->writeCommands();
346 2
            $result = null;
347 37
        } elseif ($args->getOpt('help') || $args->getOpt('?')) {
348
            // Write the help.
349 4
            $this->writeUsage($args);
350 4
            $this->writeHelp($args->getCommand());
351 4
            $result = null;
352
        } else {
353
            // Validate the arguments against the schema.
354 33
            $validArgs = $this->validate($args);
355 33
            $result = $validArgs;
356
        }
357 39
        if (!$exit) {
358 38
            $this->formatOutput = $formatOutputBak;
359 38
            $output = ob_get_clean();
360 38
            if ($result === null) {
361 38
                throw new \Exception(trim($output));
362
            }
363 1
        } elseif ($result === null) {
364
            exit();
365
        }
366 25
        return $result;
367
    }
368
369
    /**
370
     * Parse an array of arguments.
371
     *
372
     * If the first item in the array is in the form of a command (no preceding - or --),
373
     * 'command' is filled with its value.
374
     *
375
     * @param array $argv An array of arguments passed in a form compatible with the global `$argv` variable.
376
     * @return Args Returns the raw parsed arguments.
377
     * @throws \Exception Throws an exception when {@see $argv} isn't an array.
378
     */
379 39
    protected function parseRaw($argv = null) {
380 39
        if ($argv === null) {
381
            $argv = $GLOBALS['argv'];
382
        }
383
384 39
        if (!is_array($argv)) {
385
            throw new \Exception(__METHOD__ . " expects an array", 400);
386
        }
387
388 39
        $path = array_shift($argv);
389 39
        $hasCommand = $this->hasCommand();
390
391 39
        $parsed = new Args();
392 39
        $parsed->setMeta('path', $path);
393 39
        $parsed->setMeta('filename', basename($path));
394
395 39
        if (count($argv)) {
396
            // Get possible command.
397 38
            if (substr($argv[0], 0, 1) != '-') {
398 4
                $arg0 = array_shift($argv);
399 4
                if ($hasCommand) {
400 2
                    $parsed->setCommand($arg0);
401
                } else {
402 2
                    $schema = $this->getSchema($parsed->getCommand());
403 2
                    $this->addArg($schema, $parsed, $arg0);
404
                }
405
            }
406
            // Get the data types for all of the commands.
407 38
            if (!isset($schema)) {
408 36
                $schema = $this->getSchema($parsed->getCommand());
409
            }
410
411 38
            $types = [];
412 38
            foreach ($schema as $sName => $sRow) {
413 38
                if ($sName === Cli::META) {
414 38
                    continue;
415
                }
416
417 36
                $type = Cli::val('type', $sRow, 'string');
418 36
                $types[$sName] = $type;
419 36
                if (isset($sRow['short'])) {
420 36
                    $types[$sRow['short']] = $type;
421
                }
422
            }
423
424
            // Parse opts.
425 38
            for ($i = 0; $i < count($argv); $i++) {
426 38
                $str = $argv[$i];
427
428
                // Parse the help command as a boolean since it is not part of the schema!
429 38
                if (in_array($str, ['-?', '--help'])) {
430 6
                    $parsed->setOpt('help', true);
431 6
                    continue;
432
                }
433
434 32
                if ($str === '--') {
435
                    // --
436
                    $i++;
437
                    break;
438 32
                } elseif (strlen($str) > 2 && substr($str, 0, 2) == '--') {
439
                    // --foo
440 16
                    $str = substr($str, 2);
441 16
                    $parts = explode('=', $str);
442 16
                    $key = $parts[0];
443 16
                    $v = null;
444
445
                    // Has a =, so pick the second piece
446 16
                    if (count($parts) == 2) {
447 9
                        $v = $parts[1];
448
                    // Does not have an =
449
                    } else {
450
                        // If there is a value (even if there are no equals)
451 9
                        if (isset($argv[$i + 1]) && preg_match('/^--?.+/', $argv[$i + 1]) == 0) {
452
                            // so choose the next arg as its value if any,
453 6
                            $v = $argv[$i + 1];
454
                            // If this is a boolean we need to coerce the value
455 6
                            if (Cli::val($key, $types) === 'boolean') {
456 1
                                if (in_array($v, ['0', '1', 'true', 'false', 'on', 'off', 'yes', 'no'])) {
457
                                    // The next arg looks like a boolean to me.
458 1
                                    $i++;
459
                                } else {
460
                                    // Next arg is not a boolean: set the flag on, and use next arg in its own iteration
461 1
                                    $v = true;
462
                                }
463
                            } else {
464 6
                                $i++;
465
                            }
466
                        // If there is no value but we have a no- before the command
467 5
                        } elseif (strpos($key, 'no-') === 0) {
468 2
                            $tmpKey = str_replace('no-', null, $key);
469 2
                            if (Cli::val($tmpKey, $types) === 'boolean') {
470 2
                                $key = $tmpKey;
471 2
                                $v = false;
472
                            }
473 3
                        } elseif (Cli::val($key, $types) === 'boolean') {
474 2
                            $v = true;
475
                        }
476
                    }
477 16
                    $parsed->setOpt($key, $v);
478 24
                } elseif (strlen($str) == 2 && $str[0] == '-') {
479
                    // -a
480
481 9
                    $key = $str[1];
482 9
                    $type = Cli::val($key, $types, 'boolean');
483 9
                    $v = null;
484
485 9
                    if (isset($argv[$i + 1])) {
486
                        // Try and be smart about the next arg.
487 6
                        $nextArg = $argv[$i + 1];
488
489 6
                        if ($type === 'boolean') {
490 2
                            if ($this->isStrictBoolean($nextArg)) {
491
                                // The next arg looks like a boolean to me.
492 1
                                $v = $nextArg;
493 1
                                $i++;
494
                            } else {
495 2
                                $v = true;
496
                            }
497 5
                        } elseif (!preg_match('/^--?.+/', $argv[$i + 1])) {
498
                            // The next arg is not an opt.
499 5
                            $v = $nextArg;
500 5
                            $i++;
501
                        } else {
502
                            // The next arg is another opt.
503
                            $v = null;
504
                        }
505
                    }
506
507 9
                    if ($v === null) {
508 3
                        $v = Cli::val($type, ['boolean' => true, 'integer' => 1, 'string' => '']);
509
                    }
510
511 9
                    $parsed->setOpt($key, $v);
512 18
                } elseif (strlen($str) > 1 && $str[0] == '-') {
513
                    // -abcdef
514 17
                    for ($j = 1; $j < strlen($str); $j++) {
515 17
                        $opt = $str[$j];
516 17
                        $remaining = substr($str, $j + 1);
517 17
                        $type = Cli::val($opt, $types, 'boolean');
518
519
                        // Check for an explicit equals sign.
520 17
                        if (substr($remaining, 0, 1) === '=') {
521 3
                            $remaining = substr($remaining, 1);
522 3
                            if ($type === 'boolean') {
523
                                // Bypass the boolean flag checking below.
524
                                $parsed->setOpt($opt, $remaining);
525
                                break;
526
                            }
527
                        }
528
529 17
                        if ($type === 'boolean') {
530 5
                            if (preg_match('`^([01])`', $remaining, $matches)) {
531
                                // Treat the 0 or 1 as a true or false.
532 3
                                $parsed->setOpt($opt, $matches[1]);
533 3
                                $j += strlen($matches[1]);
534
                            } else {
535
                                // Treat the option as a flag.
536 5
                                $parsed->setOpt($opt, true);
537
                            }
538 16
                        } elseif ($type === 'string') {
539
                            // Treat the option as a set with no = sign.
540 14
                            $parsed->setOpt($opt, $remaining);
541 14
                            break;
542 3
                        } elseif ($type === 'integer') {
543 3
                            if (preg_match('`^(\d+)`', $remaining, $matches)) {
544
                                // Treat the option as a set with no = sign.
545 2
                                $parsed->setOpt($opt, $matches[1]);
546 2
                                $j += strlen($matches[1]);
547
                            } else {
548
                                // Treat the option as either multiple flags.
549 2
                                $optVal = $parsed->getOpt($opt, 0);
550 3
                                $parsed->setOpt($opt, $optVal + 1);
551
                            }
552
                        } else {
553
                            // This should not happen unless we've put a bug in our code.
554
                            throw new \Exception("Invalid type $type for $opt.", 500);
555
                        }
556
                    }
557
                } else {
558
                    // End of opts
559 1
                    break;
560
                }
561
            }
562
563
            // Grab the remaining args.
564 38
            for (; $i < count($argv); $i++) {
565 1
                $this->addArg($schema, $parsed, $argv[$i]);
566
            }
567
        }
568
569 39
        return $parsed;
570
    }
571
572
    /**
573
     * Validates arguments against the schema.
574
     *
575
     * @param Args $args The arguments that were returned from {@link Cli::parseRaw()}.
576
     * @return Args|null
577
     */
578 33
    public function validate(Args $args) {
579 33
        $isValid = true;
580 33
        $command = $args->getCommand();
581 33
        $valid = new Args($command);
582 33
        $schema = $this->getSchema($command);
583 33
        ksort($schema);
584
585
//        $meta = $schema[Cli::META];
586 33
        unset($schema[Cli::META]);
587 33
        $opts = $args->getOpts();
588 33
        $missing = [];
589
590
        // Check to see if the command is correct.
591 33
        if ($command && !$this->hasCommand($command) && $this->hasCommand()) {
592
            echo $this->red("Invalid command: $command.".PHP_EOL);
593
            $isValid = false;
594
        }
595
596
        // Add the args.
597 33
        $valid->setArgs($args->getArgs());
598
599 33
        foreach ($schema as $key => $definition) {
600
            // No Parameter (default)
601 32
            $type = Cli::val('type', $definition, 'string');
602
603 32
            if (array_key_exists($key, $opts)) {
604
                // Check for --key.
605 14
                $value = $opts[$key];
606 14
                if ($this->validateType($value, $type, $key, $definition)) {
607 11
                    $valid->setOpt($key, $value);
608
                } else {
609 3
                    $isValid = false;
610
                }
611 14
                unset($opts[$key]);
612 27
            } elseif (isset($definition['short']) && array_key_exists($definition['short'], $opts)) {
613
                // Check for -s.
614 23
                $value = $opts[$definition['short']];
615 23
                if ($this->validateType($value, $type, $key, $definition)) {
616 22
                    $valid->setOpt($key, $value);
617
                } else {
618 2
                    $isValid = false;
619
                }
620 23
                unset($opts[$definition['short']]);
621 18
            } elseif (array_key_exists('no-'.$key, $opts)) {
622
                // Check for --no-key.
623 2
                $value = $opts['no-'.$key];
624
625 2
                if ($type !== 'boolean') {
626 1
                    echo $this->red("Cannot apply the --no- prefix on the non boolean --$key.".PHP_EOL);
627 1
                    $isValid = false;
628 1
                } elseif ($this->validateType($value, $type, $key, $definition)) {
629
                    $valid->setOpt($key, !$value);
630
                } else {
631 1
                    $isValid = false;
632
                }
633 2
                unset($opts['no-'.$key]);
634 18
            } elseif ($definition['required']) {
635
                // The key was not supplied. Is it required?
636 2
                $missing[$key] = true;
637 32
                $valid->setOpt($key, false);
638
            }
639
        }
640
641 33
        if (count($missing)) {
642 2
            $isValid = false;
643 2
            foreach ($missing as $key => $v) {
644 2
                echo $this->red("Missing required option: $key".PHP_EOL);
645
            }
646
        }
647
648 33
        if (count($opts)) {
649
            $isValid = false;
650
            foreach ($opts as $key => $v) {
651
                echo $this->red("Invalid option: $key".PHP_EOL);
652
            }
653
        }
654
655 33
        if ($isValid) {
656 25
            return $valid;
657
        } else {
658 8
            echo PHP_EOL;
659 8
            return null;
660
        }
661
    }
662
663
    /**
664
     * Gets the full cli schema.
665
     *
666
     * @param string $command The name of the command. This can be left blank if there is no command.
667
     * @return array Returns the schema that matches the command.
668
     */
669 40
    public function getSchema($command = '') {
670 40
        $result = [];
671 40
        foreach ($this->commandSchemas as $pattern => $opts) {
672 40
            if (fnmatch($pattern, $command)) {
673 40
                $result = array_replace_recursive($result, $opts);
674
            }
675
        }
676 40
        return $result;
677
    }
678
679
    /**
680
     * Gets/sets the value for a current meta item.
681
     *
682
     * @param string $name The name of the meta key.
683
     * @param mixed $value Set a new value for the meta key.
684
     * @return $this|mixed Returns the current value of the meta item or `$this` for fluent setting.
685
     */
686 8
    public function meta($name, $value = null) {
687 8
        if ($value !== null) {
688 8
            $this->currentSchema[Cli::META][$name] = $value;
689 8
            return $this;
690
        }
691
        if (!isset($this->currentSchema[Cli::META][$name])) {
692
            return null;
693
        }
694
        return $this->currentSchema[Cli::META][$name];
695
    }
696
697
    /**
698
     * Adds an option (opt) to the current schema.
699
     *
700
     * @param string $name The long name(s) of the parameter.
701
     * You can use either just one name or a string in the form 'long:short' to specify the long and short name.
702
     * @param string $description A human-readable description for the column.
703
     * @param bool $required Whether or not the opt is required.
704
     * @param string $type The type of parameter.
705
     * This must be one of string, bool, integer.
706
     * @return $this
707
     * @throws \Exception Throws an exception when the type is invalid.
708
     */
709 38
    public function opt($name, $description, $required = false, $type = 'string') {
710
        switch ($type) {
711 38
            case 'str':
712 38
            case 'string':
713 36
                $type = 'string';
714 36
                break;
715 29
            case 'bool':
716 29
            case 'boolean':
717 26
                $type = 'boolean';
718 26
                break;
719 22
            case 'int':
720 22
            case 'integer':
721 22
                $type = 'integer';
722 22
                break;
723
            default:
724
                throw new \Exception("Invalid type: $type. Must be one of string, boolean, or integer.", 422);
725
        }
726
727
        // Break the name up into its long and short form.
728 38
        $parts = explode(':', $name, 2);
729 38
        $long = $parts[0];
730 38
        $short = static::val(1, $parts, '');
731
732 38
        $this->currentSchema[$long] = ['description' => $description, 'required' => $required, 'type' => $type, 'short' => $short];
733 38
        return $this;
734
    }
735
736
    /**
737
     * Define an arg on the current command.
738
     *
739
     * @param string $name The name of the arg.
740
     * @param string $description The arg description.
741
     * @param bool $required Whether or not the arg is required.
742
     * @return $this
743
     */
744 5
    public function arg($name, $description, $required = false) {
745 5
        $this->currentSchema[Cli::META][Cli::ARGS][$name] =
746 5
            ['description' => $description, 'required' => $required];
747 5
        return $this;
748
    }
749
750
    /**
751
     * Selects the current command schema name.
752
     *
753
     * @param string $pattern The command pattern.
754
     * @return $this
755
     */
756 4
    public function command($pattern) {
757 4
        if (!isset($this->commandSchemas[$pattern])) {
758 4
            $this->commandSchemas[$pattern] = [Cli::META => []];
759
        }
760 4
        $this->currentSchema =& $this->commandSchemas[$pattern];
761
762 4
        return $this;
763
    }
764
765
766
    /**
767
     * Determine weather or not a value can be represented as a boolean.
768
     *
769
     * This method is sort of like {@link Cli::validateType()} but requires a more strict check of a boolean value.
770
     *
771
     * @param mixed $value The value to test.
772
     * @return bool
773
     */
774 2
    protected function isStrictBoolean($value, &$boolValue = null) {
775 2
        if ($value === true || $value === false) {
776
            $boolValue = $value;
777
            return true;
778 2
        } elseif (in_array($value, ['0', 'false', 'off', 'no'])) {
779 1
            $boolValue = false;
780 1
            return true;
781 2
        } elseif (in_array($value, ['1', 'true', 'on', 'yes'])) {
782
            $boolValue = true;
783
            return true;
784
        } else {
785 2
            $boolValue = null;
786 2
            return false;
787
        }
788
    }
789
790
    /**
791
     * Set the schema for a command.
792
     *
793
     * The schema array uses a short syntax so that commands can be specified as quickly as possible.
794
     * This schema is the exact same as those provided to {@link Schema::create()}.
795
     * The basic format of the array is the following:
796
     *
797
     * ```
798
     * [
799
     *     type:name[:shortCode][?],
800
     *     type:name[:shortCode][?],
801
     *     ...
802
     * ]
803
     * ```
804
     *
805
     * @param array $schema The schema array.
806
     */
807 1
    public function schema(array $schema) {
808 1
        $parsed = static::parseSchema($schema);
809
810 1
        $this->currentSchema = array_replace($this->currentSchema, $parsed);
811 1
    }
812
813
    /**
814
     * Bold some text.
815
     *
816
     * @param string $text The text to format.
817
     * @return string Returns the text surrounded by formatting commands.
818
     */
819 7
    public function bold($text) {
820 7
        return $this->formatString($text, ["\033[1m", "\033[0m"]);
821
    }
822
823
    /**
824
     * Bold some text.
825
     *
826
     * @param string $text The text to format.
827
     * @return string Returns the text surrounded by formatting commands.
828
     */
829
    public static function boldText($text) {
830
        return "\033[1m{$text}\033[0m";
831
    }
832
833
    /**
834
     * Make some text red.
835
     *
836
     * @param string $text The text to format.
837
     * @return string Returns  text surrounded by formatting commands.
838
     */
839 8
    public function red($text) {
840 8
        return $this->formatString($text, ["\033[1;31m", "\033[0m"]);
841
    }
842
843
    /**
844
     * Make some text red.
845
     *
846
     * @param string $text The text to format.
847
     * @return string Returns  text surrounded by formatting commands.
848
     */
849 1
    public static function redText($text) {
850 1
        return "\033[1;31m{$text}\033[0m";
851
    }
852
853
    /**
854
     * Make some text green.
855
     *
856
     * @param string $text The text to format.
857
     * @return string Returns  text surrounded by formatting commands.
858
     */
859
    public function green($text) {
860
        return $this->formatString($text, ["\033[1;32m", "\033[0m"]);
861
    }
862
863
    /**
864
     * Make some text green.
865
     *
866
     * @param string $text The text to format.
867
     * @return string Returns  text surrounded by formatting commands.
868
     */
869 1
    public static function greenText($text) {
870 1
        return "\033[1;32m{$text}\033[0m";
871
    }
872
873
    /**
874
     * Make some text blue.
875
     *
876
     * @param string $text The text to format.
877
     * @return string Returns  text surrounded by formatting commands.
878
     */
879
    public function blue($text) {
880
        return $this->formatString($text, ["\033[1;34m", "\033[0m"]);
881
    }
882
883
    /**
884
     * Make some text blue.
885
     *
886
     * @param string $text The text to format.
887
     * @return string Returns  text surrounded by formatting commands.
888
     */
889
    public static function blueText($text) {
890
        return "\033[1;34m{$text}\033[0m";
891
    }
892
893
    /**
894
     * Make some text purple.
895
     *
896
     * @param string $text The text to format.
897
     * @return string Returns  text surrounded by formatting commands.
898
     */
899
    public function purple($text) {
900
        return $this->formatString($text, ["\033[0;35m", "\033[0m"]);
901
    }
902
903
    /**
904
     * Make some text purple.
905
     *
906
     * @param string $text The text to format.
907
     * @return string Returns  text surrounded by formatting commands.
908
     */
909
    public static function purpleText($text) {
910
        return "\033[0;35m{$text}\033[0m";
911
    }
912
913
    /**
914
     * Format some text for the console.
915
     *
916
     * @param string $text The text to format.
917
     * @param string[] $wrap The format to wrap in the form ['before', 'after'].
918
     * @return string Returns the string formatted according to {@link Cli::$format}.
919
     */
920 15
    protected function formatString($text, array $wrap) {
921 15
        if ($this->formatOutput) {
922 1
            return "{$wrap[0]}$text{$wrap[1]}";
923
        } else {
924 14
            return $text;
925
        }
926
    }
927
928
    /**
929
     * Guess whether or not to format the output with colors.
930
     *
931
     * If the current environment is being redirected to a file then output should not be formatted. Also, Windows
932
     * machines do not support terminal colors so formatting should be suppressed on them too.
933
     *
934
     * @param mixed The stream to interrogate for output format support.
935
     * @return bool Returns **true** if the output can be formatter or **false** otherwise.
936
     */
937 105
    public static function guessFormatOutput($stream = STDOUT) {
938 105
        if (defined('PHP_WINDOWS_VERSION_MAJOR')) {
939
            return false;
940 105
        } elseif (function_exists('posix_isatty')) {
941
            try {
942 105
                return @posix_isatty($stream);
943
            } catch (\Throwable $ex) {
944
                return false;
945
            }
946
        } else {
947
            return true;
948
        }
949
    }
950
951
    /**
952
     * Sleep for a number of seconds, echoing out a dot on each second.
953
     *
954
     * @param int $seconds The number of seconds to sleep.
955
     */
956
    public static function sleep($seconds) {
957
        for ($i = 0; $i < $seconds; $i++) {
958
            sleep(1);
959
            echo '.';
960
        }
961
    }
962
963
    /**
964
     * Validate the type of a value and coerce it into the proper type.
965
     *
966
     * @param mixed &$value The value to validate.
967
     * @param string $type One of: bool, int, string.
968
     * @param string $name The name of the option if you want to print an error message.
969
     * @param array|null $def The option def if you want to print an error message.
970
     * @return bool Returns `true` if the value is the correct type.
971
     * @throws \Exception Throws an exception when {@see $type} is not a known value.
972
     */
973 31
    protected function validateType(&$value, $type, $name = '', $def = null) {
974
        switch ($type) {
975 31
            case 'boolean':
976 15
                if (is_bool($value)) {
977 10
                    $valid = true;
978 8
                } elseif ($value === 0) {
979
                    // 0 doesn't work well with in_array() so check it separately.
980
                    $value = false;
981
                    $valid = true;
982 8
                } elseif (in_array($value, [null, '', '0', 'false', 'no', 'disabled'])) {
983 5
                    $value = false;
984 5
                    $valid = true;
985 5
                } elseif (in_array($value, [1, '1', 'true', 'yes', 'enabled'])) {
986 2
                    $value = true;
987 2
                    $valid = true;
988
                } else {
989 3
                    $valid = false;
990
                }
991 15
                break;
992 28
            case 'integer':
993 9
                if (is_numeric($value)) {
994 6
                    $value = (int)$value;
995 6
                    $valid = true;
996
                } else {
997 3
                    $valid = false;
998
                }
999 9
                break;
1000 27
            case 'string':
1001 27
                $value = (string)$value;
1002 27
                $valid = true;
1003 27
                break;
1004
            default:
1005
                throw new \Exception("Unknown type: $type.", 400);
1006
        }
1007
1008 31
        if (!$valid && $name) {
1009 6
            $short = static::val('short', (array)$def);
1010 6
            $nameStr = "--$name".($short ? " (-$short)" : '');
1011 6
            echo $this->red("The value of $nameStr is not a valid $type.".PHP_EOL);
1012
        }
1013
1014 31
        return $valid;
1015
    }
1016
1017
    /**
1018
     * Writes a lis of all of the commands.
1019
     */
1020 2
    protected function writeCommands() {
1021 2
        echo static::bold("COMMANDS").PHP_EOL;
1022
1023 2
        $table = new Table();
1024 2
        foreach ($this->commandSchemas as $pattern => $schema) {
1025 2
            if (static::isCommand($pattern)) {
1026
                $table
1027 2
                    ->row()
1028 2
                    ->cell($pattern)
1029 2
                    ->cell(Cli::val('description', Cli::val(Cli::META, $schema), ''));
1030
            }
1031
        }
1032 2
        $table->write();
1033 2
    }
1034
1035
    /**
1036
     * Writes the cli help.
1037
     *
1038
     * @param string $command The name of the command or blank if there is no command.
1039
     */
1040 5
    public function writeHelp($command = '') {
1041 5
        $schema = $this->getSchema($command);
1042 5
        $this->writeSchemaHelp($schema);
1043 5
    }
1044
1045
    /**
1046
     * Writes the help for a given schema.
1047
     *
1048
     * @param array $schema A command line scheme returned from {@see Cli::getSchema()}.
1049
     */
1050 5
    protected function writeSchemaHelp($schema) {
1051
        // Write the command description.
1052 5
        $meta = Cli::val(Cli::META, $schema, []);
1053 5
        $description = Cli::val('description', $meta);
1054
1055 5
        if ($description) {
1056 3
            echo implode("\n", Cli::breakLines($description, 80, false)).PHP_EOL.PHP_EOL;
1057
        }
1058
1059 5
        unset($schema[Cli::META]);
1060
1061
        // Add the help.
1062 5
        $schema['help'] = [
1063
            'description' => 'Display this help.',
1064
            'type' => 'boolean',
1065
            'short' => '?'
1066
        ];
1067
1068 5
        echo Cli::bold('OPTIONS').PHP_EOL;
1069
1070 5
        ksort($schema);
1071
1072 5
        $table = new Table();
1073 5
        $table->setFormatOutput($this->formatOutput);
1074
1075 5
        foreach ($schema as $key => $definition) {
1076 5
            $table->row();
1077
1078
            // Write the keys.
1079 5
            $keys = "--{$key}";
1080 5
            if ($shortKey = Cli::val('short', $definition, false)) {
1081 5
                $keys .= ", -$shortKey";
1082
            }
1083 5
            if (Cli::val('required', $definition)) {
1084 3
                $table->bold($keys);
1085
            } else {
1086 5
                $table->cell($keys);
1087
            }
1088
1089
            // Write the description.
1090 5
            $table->cell(Cli::val('description', $definition, ''));
1091
        }
1092
1093 5
        $table->write();
1094 5
        echo PHP_EOL;
1095
1096 5
        $args = Cli::val(Cli::ARGS, $meta, []);
1097 5
        if (!empty($args)) {
1098 2
            echo Cli::bold('ARGUMENTS').PHP_EOL;
1099
1100 2
            $table = new Table();
1101 2
            $table->setFormatOutput($this->formatOutput);
1102
1103 2
            foreach ($args as $argName => $arg) {
1104 2
                $table->row();
1105
1106 2
                if (Cli::val('required', $arg)) {
1107 1
                    $table->bold($argName);
1108
                } else {
1109 1
                    $table->cell($argName);
1110
                }
1111
1112 2
                $table->cell(Cli::val('description', $arg, ''));
1113
            }
1114 2
            $table->write();
1115 2
            echo PHP_EOL;
1116
        }
1117 5
    }
1118
1119
    /**
1120
     * Writes the basic usage information of the command.
1121
     *
1122
     * @param Args $args The parsed args returned from {@link Cli::parseRaw()}.
1123
     */
1124 6
    protected function writeUsage(Args $args) {
1125 6
        if ($filename = $args->getMeta('filename')) {
1126 6
            $schema = $this->getSchema($args->getCommand());
1127 6
            unset($schema[Cli::META]);
1128
1129 6
            echo static::bold("usage: ").$filename;
1130
1131 6
            if ($this->hasCommand()) {
1132 4
                if ($args->getCommand() && isset($this->commandSchemas[$args->getCommand()])) {
1133 2
                    echo ' '.$args->getCommand();
1134
1135
                } else {
1136 2
                    echo ' <command>';
1137
                }
1138
            }
1139
1140 6
            if ($this->hasOptions($args->getCommand())) {
1141 6
                echo " [<options>]";
1142
            }
1143
1144 6
            if ($hasArgs = $this->hasArgs($args->getCommand())) {
1145 4
                echo $hasArgs === 2 ? " <args>" : " [<args>]";
1146
            }
1147
1148 6
            echo PHP_EOL.PHP_EOL;
1149
        }
1150 6
    }
1151
1152
    /**
1153
     * Parse a schema in short form into a full schema array.
1154
     *
1155
     * @param array $arr The array to parse into a schema.
1156
     * @return array The full schema array.
1157
     * @throws \InvalidArgumentException Throws an exception when an item in the schema is invalid.
1158
     */
1159 1
    public static function parseSchema(array $arr) {
1160 1
        $result = [];
1161
1162 1
        foreach ($arr as $key => $value) {
1163 1
            if (is_int($key)) {
1164 1
                if (is_string($value)) {
1165
                    // This is a short param value.
1166 1
                    $param = static::parseShortParam($value);
1167 1
                    $name = $param['name'];
1168 1
                    $result[$name] = $param;
1169
                } else {
1170 1
                    throw new \InvalidArgumentException("Schema at position $key is not a valid param.", 500);
1171
                }
1172
            } else {
1173
                // The parameter is defined in the key.
1174 1
                $param = static::parseShortParam($key, $value);
1175 1
                $name = $param['name'];
1176
1177 1
                if (is_array($value)) {
1178
                    // The value describes a bit more about the schema.
1179
                    switch ($param['type']) {
1180
                        case 'array':
1181
                            if (isset($value['items'])) {
1182
                                // The value includes array schema information.
1183
                                $param = array_replace($param, $value);
1184
                            } else {
1185
                                // The value is a schema of items.
1186
                                $param['items'] = $value;
1187
                            }
1188
                            break;
1189
                        case 'object':
1190
                            // The value is a schema of the object.
1191
                            $param['properties'] = static::parseSchema($value);
1192
                            break;
1193
                        default:
1194
                            $param = array_replace($param, $value);
1195
                            break;
1196
                    }
1197 1
                } elseif (is_string($value)) {
1198 1
                    if ($param['type'] === 'array') {
1199
                        // Check to see if the value is the item type in the array.
1200
                        if (isset(self::$types[$value])) {
1201
                            $arrType = self::$types[$value];
1202
                        } elseif (($index = array_search($value, self::$types)) !== false) {
0 ignored issues
show
The assignment to $index is dead and can be removed.
Loading history...
1203
                            $arrType = self::$types[$value];
1204
                        }
1205
1206
                        if (isset($arrType)) {
1207
                            $param['items'] = ['type' => $arrType];
1208
                        } else {
1209
                            $param['description'] = $value;
1210
                        }
1211
                    } else {
1212
                        // The value is the schema description.
1213 1
                        $param['description'] = $value;
1214
                    }
1215
                }
1216
1217 1
                $result[$name] = $param;
1218
            }
1219
        }
1220
1221 1
        return $result;
1222
    }
1223
1224
    /**
1225
     * Parse a short parameter string into a full array parameter.
1226
     *
1227
     * @param string $str The short parameter string to parse.
1228
     * @param array $other An array of other information that might help resolve ambiguity.
1229
     * @return array Returns an array in the form [name, [param]].
1230
     * @throws \InvalidArgumentException Throws an exception if the short param is not in the correct format.
1231
     */
1232 1
    protected static function parseShortParam($str, $other = []) {
1233
        // Is the parameter optional?
1234 1
        if (substr($str, -1) === '?') {
1235 1
            $required = false;
1236 1
            $str = substr($str, 0, -1);
1237
        } else {
1238 1
            $required = true;
1239
        }
1240
1241
        // Check for a type.
1242 1
        $parts = explode(':', $str);
1243
1244 1
        if (count($parts) === 1) {
1245 1
            if (isset($other['type'])) {
1246
                $type = $other['type'];
1247
            } else {
1248 1
                $type = 'string';
1249
            }
1250 1
            $name = $parts[0];
1251
        } else {
1252 1
            $name = $parts[1];
1253
1254 1
            if (isset(self::$types[$parts[0]])) {
1255 1
                $type = self::$types[$parts[0]];
1256
            } else {
1257
                throw new \InvalidArgumentException("Invalid type {$parts[1]} for field $name.", 500);
1258
            }
1259
1260 1
            if (isset($parts[2])) {
1261 1
                $short = $parts[2];
1262
            }
1263
        }
1264
1265 1
        $result = ['name' => $name, 'type' => $type, 'required' => $required];
1266
1267 1
        if (isset($short)) {
1268 1
            $result['short'] = $short;
1269
        }
1270
1271 1
        return $result;
1272
    }
1273
1274
    /**
1275
     * Safely get a value out of an array.
1276
     *
1277
     * This function uses optimizations found in the [facebook libphputil library](https://github.com/facebook/libphutil).
1278
     *
1279
     * @param string|int $key The array key.
1280
     * @param array $array The array to get the value from.
1281
     * @param mixed $default The default value to return if the key doesn't exist.
1282
     * @return mixed The item from the array or `$default` if the array key doesn't exist.
1283
     */
1284 41
    public static function val($key, array $array, $default = null) {
1285
        // isset() is a micro-optimization - it is fast but fails for null values.
1286 41
        if (isset($array[$key])) {
1287 40
            return $array[$key];
1288
        }
1289
1290
        // Comparing $default is also a micro-optimization.
1291 41
        if ($default === null || array_key_exists($key, $array)) {
1292 39
            return null;
1293
        }
1294
1295 10
        return $default;
1296
    }
1297
}
1298